Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
449 changes: 449 additions & 0 deletions src/app/bookmarks/page.tsx

Large diffs are not rendered by default.

175 changes: 175 additions & 0 deletions src/components/bookmarks/BookmarkForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
'use client';

import { useState, useEffect } from 'react';
import { BookmarkFormData, Bookmark } from '@/types/bookmark';

interface BookmarkFormProps {
bookmark?: Bookmark; // ์ˆ˜์ • ๋ชจ๋“œ์ผ ๋•Œ ์ „๋‹ฌ
onSubmit: (data: BookmarkFormData) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}

export default function BookmarkForm({
bookmark,
onSubmit,
onCancel,
isLoading,
}: BookmarkFormProps) {
const [formData, setFormData] = useState<BookmarkFormData>({
custom_name: '',
custom_url: '',
favicon: '',
});

const [errors, setErrors] = useState<Partial<BookmarkFormData>>({});

useEffect(() => {
if (bookmark) {
setFormData({
custom_name: bookmark.custom_name,
custom_url: bookmark.custom_url,
favicon: bookmark.favicon || '',
});
}
}, [bookmark]);

const validateForm = (): boolean => {
const newErrors: Partial<BookmarkFormData> = {};

if (!formData.custom_name.trim()) {
newErrors.custom_name = '๋ถ๋งˆํฌ ์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.';
}

if (!formData.custom_url.trim()) {
newErrors.custom_url = 'URL์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.';
} else if (!isValidUrl(formData.custom_url)) {
newErrors.custom_url = '์˜ฌ๋ฐ”๋ฅธ URL ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.';
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const isValidUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!validateForm()) return;

try {
await onSubmit(formData);
} catch (error) {
console.error('Form submission error:', error);
}
};

const handleChange = (field: keyof BookmarkFormData, value: string) => {
setFormData((prev: BookmarkFormData) => ({ ...prev, [field]: value }));
// ์—๋Ÿฌ๊ฐ€ ์žˆ์—ˆ๋‹ค๋ฉด ์ž…๋ ฅ์‹œ ์ œ๊ฑฐ
if (errors[field]) {
setErrors((prev: Partial<BookmarkFormData>) => ({
...prev,
[field]: undefined,
}));
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* ๋ถ๋งˆํฌ ์ด๋ฆ„ */}
<div>
<label
htmlFor="custom_name"
className="block text-sm font-medium text-gray-700 mb-1"
>
๋ถ๋งˆํฌ ์ด๋ฆ„ *
</label>
<input
id="custom_name"
type="text"
value={formData.custom_name}
onChange={(e) => handleChange('custom_name', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.custom_name ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="์˜ˆ: ์ธํ„ฐํŒŒํฌ ํ‹ฐ์ผ“"
disabled={isLoading}
/>
{errors.custom_name && (
<p className="mt-1 text-sm text-red-600">{errors.custom_name}</p>
)}
</div>

{/* URL */}
<div>
<label
htmlFor="custom_url"
className="block text-sm font-medium text-gray-700 mb-1"
>
URL *
</label>
<input
id="custom_url"
type="url"
value={formData.custom_url}
onChange={(e) => handleChange('custom_url', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.custom_url ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="https://example.com"
disabled={isLoading}
/>
{errors.custom_url && (
<p className="mt-1 text-sm text-red-600">{errors.custom_url}</p>
)}
</div>

{/* ํŒŒ๋น„์ฝ˜ URL (์„ ํƒ์‚ฌํ•ญ) */}
<div>
<label
htmlFor="favicon"
className="block text-sm font-medium text-gray-700 mb-1"
>
ํŒŒ๋น„์ฝ˜ URL (์„ ํƒ์‚ฌํ•ญ)
</label>
<input
id="favicon"
type="url"
value={formData.favicon}
onChange={(e) => handleChange('favicon', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com/favicon.ico"
disabled={isLoading}
/>
</div>

{/* ๋ฒ„ํŠผ๋“ค */}
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 disabled:opacity-50"
>
์ทจ์†Œ
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? '์ฒ˜๋ฆฌ์ค‘...' : bookmark ? '์ˆ˜์ •' : '์ถ”๊ฐ€'}
</button>
</div>
</form>
);
}
153 changes: 153 additions & 0 deletions src/components/bookmarks/BookmarkItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client';

import { Bookmark } from '@/types/bookmark';

interface BookmarkItemProps {
bookmark: Bookmark;
onEdit: (bookmark: Bookmark) => void;
onDelete: (id: number) => void;
onCheckTime: (bookmark: Bookmark) => void;
viewMode: 'grid' | 'list';
}

export default function BookmarkItem({
bookmark,
onEdit,
onDelete,
onCheckTime,
viewMode,
}: BookmarkItemProps) {
const getFaviconUrl = (url: string) => {
try {
const urlObj = new URL(url);
return `${urlObj.origin}/favicon.ico`;
} catch {
return null;
}
};

const faviconUrl = getFaviconUrl(bookmark.custom_url);

if (viewMode === 'list') {
return (
<div
className="bg-gray-50 border border-gray-200 rounded-xl p-6 hover:border-indigo-500 transition-all cursor-pointer group"
onClick={() => onCheckTime(bookmark)}
>
<div className="flex items-center gap-4">
{/* ์•„์ด์ฝ˜ */}
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-xl text-white flex-shrink-0"
>
{bookmark.favicon && faviconUrl ? (
<img
src={faviconUrl}
alt={bookmark.custom_name}
className="w-8 h-8 rounded"
onError={(e) => {
e.currentTarget.style.display = 'none';
const nextElement = e.currentTarget.nextElementSibling as HTMLElement;
if (nextElement) nextElement.style.display = 'block';
}}
/>
) : null}
<span style={{ display: bookmark.favicon && faviconUrl ? 'none' : 'block' }}>
</span>
</div>

{/* ๋‚ด์šฉ */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{bookmark.custom_name}
</h3>
<p className="text-sm text-gray-600 truncate">
{bookmark.custom_url}
</p>
</div>

{/* ์•ก์…˜ ๋ฒ„ํŠผ๋“ค */}
<div className="flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onEdit(bookmark);
}}
className="px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 transition-colors"
>
์ˆ˜์ •
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(bookmark.id);
}}
className="px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 transition-colors"
>
์‚ญ์ œ
</button>
</div>
</div>
</div>
);
}

// Grid view
return (
<div
className="bg-gray-50 border border-gray-200 rounded-xl p-6 text-center hover:border-indigo-500 transition-all cursor-pointer group"
onClick={() => onCheckTime(bookmark)}
>
{/* ์•„์ด์ฝ˜ */}
<div
className="w-12 h-12 rounded-lg mx-auto mb-4 flex items-center justify-center text-xl text-white"
>
{bookmark.favicon && faviconUrl ? (
<img
src={faviconUrl}
alt={bookmark.custom_name}
className="w-8 h-8 rounded"
onError={(e) => {
e.currentTarget.style.display = 'none';
const nextElement = e.currentTarget.nextElementSibling as HTMLElement;
if (nextElement) nextElement.style.display = 'block';
}}
/>
) : null}
<span style={{ display: bookmark.favicon && faviconUrl ? 'none' : 'block' }}>
</span>
</div>

{/* ์ œ๋ชฉ */}
<h3 className="text-base font-semibold text-gray-900 mb-2 truncate">
{bookmark.custom_name}
</h3>

{/* URL */}
<p className="text-xs text-gray-600 mb-4 break-all">
{bookmark.custom_url}
</p>

{/* ์•ก์…˜ ๋ฒ„ํŠผ๋“ค */}
<div className="flex gap-2 justify-center">
<button
onClick={(e) => {
e.stopPropagation();
onEdit(bookmark);
}}
className="px-2.5 py-1 text-xs font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 transition-colors"
>
์ˆ˜์ •
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(bookmark.id);
}}
className="px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 border border-red-200 rounded hover:bg-red-100 transition-colors"
>
์‚ญ์ œ
</button>
</div>
</div>
);
}
Loading