diff --git a/packages/web/src/app/(admin)/admin/curation/items/page.tsx b/packages/web/src/app/(admin)/admin/curation/items/page.tsx
index 08d2516..d536a64 100644
--- a/packages/web/src/app/(admin)/admin/curation/items/page.tsx
+++ b/packages/web/src/app/(admin)/admin/curation/items/page.tsx
@@ -13,6 +13,16 @@ import {
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
interface CurationItem {
id: string;
@@ -58,6 +68,9 @@ export default function CurationItemsPage() {
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState(null);
+ // Delete confirmation state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
// Filters
const [category, setCategory] = useState('all');
const [sourceId, setSourceId] = useState('all');
@@ -101,21 +114,25 @@ export default function CurationItemsPage() {
};
const handleDelete = async (item: CurationItem) => {
- if (!confirm(`"${item.title}" 아이템을 삭제하시겠습니까?`)) return;
+ setDeleteTarget(item);
+ };
+
+ const confirmDelete = async () => {
+ if (!deleteTarget) return;
try {
- setDeletingId(item.id);
- const response = await fetch(`/api/admin/curation/items/${item.id}`, {
+ setDeletingId(deleteTarget.id);
+ const response = await fetch(`/api/admin/curation/items/${deleteTarget.id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete item');
+ setDeleteTarget(null);
// Refresh current page
fetchItems(pagination.page, category, sourceId);
} catch (err) {
console.error('Error deleting item:', err);
- alert('삭제에 실패했습니다.');
} finally {
setDeletingId(null);
}
@@ -206,7 +223,10 @@ export default function CurationItemsPage() {
className="font-medium text-sm hover:underline line-clamp-2 flex items-start gap-1 group"
>
{item.title}
-
+
+
+ (새 탭에서 열기)
+
{/* Source + Date */}
@@ -280,6 +300,22 @@ export default function CurationItemsPage() {
)}
+
+ {/* Delete Confirmation Dialog */}
+ !open && setDeleteTarget(null)}>
+
+
+ 아이템 삭제
+
+ “{deleteTarget?.title}” 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+
+
+
+ 취소
+ 삭제
+
+
+
);
}
diff --git a/packages/web/src/app/(admin)/admin/curation/page.tsx b/packages/web/src/app/(admin)/admin/curation/page.tsx
index b6baaaa..bb736e8 100644
--- a/packages/web/src/app/(admin)/admin/curation/page.tsx
+++ b/packages/web/src/app/(admin)/admin/curation/page.tsx
@@ -12,6 +12,17 @@ import {
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
import { AdminDashboardSkeleton, PageError } from '@/components/ui/page-state';
import { CrawlModal } from './crawl-modal';
import type { CrawlStatus, CrawlSourceResult, CrawlSummary } from './crawl-modal';
@@ -60,6 +71,9 @@ export default function AdminCurationPage() {
const [editError, setEditError] = useState(null);
const [saving, setSaving] = useState(false);
+ // Delete confirmation state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+
// Crawl modal state
const [crawlModalOpen, setCrawlModalOpen] = useState(false);
const [crawlStatus, setCrawlStatus] = useState('idle');
@@ -185,17 +199,15 @@ export default function AdminCurationPage() {
};
const handleDelete = async (source: CurationSource) => {
- if (
- !confirm(
- `"${source.name}" 소스를 삭제하시겠습니까?\n수집된 ${source.itemCount}개의 아이템도 함께 삭제됩니다.`,
- )
- ) {
- return;
- }
+ setDeleteTarget(source);
+ };
+
+ const confirmDelete = async () => {
+ if (!deleteTarget) return;
try {
- setUpdatingId(source.id);
- const response = await fetch(`/api/admin/curation/${source.id}`, {
+ setUpdatingId(deleteTarget.id);
+ const response = await fetch(`/api/admin/curation/${deleteTarget.id}`, {
method: 'DELETE',
});
@@ -203,6 +215,7 @@ export default function AdminCurationPage() {
throw new Error('Failed to delete source');
}
+ setDeleteTarget(null);
await fetchSources();
} catch (err) {
console.error('Error deleting source:', err);
@@ -361,6 +374,23 @@ export default function AdminCurationPage() {
errorMessage={crawlErrorMessage}
/>
+ {/* Delete Confirmation Dialog */}
+ !open && setDeleteTarget(null)}>
+
+
+ 소스 삭제
+
+ “{deleteTarget?.name}” 소스를 삭제하시겠습니까? 수집된 {deleteTarget?.itemCount}개의
+ 아이템도 함께 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
+
+
+
+ 취소
+ 삭제
+
+
+
+
{/* Stats Cards */}
@@ -443,8 +473,12 @@ export default function AdminCurationPage() {
{filteredSources.length}개의 소스
-
+
+
-
+
+
{/* Error messages */}
{errors.length > 0 && (
-
+
@@ -216,6 +223,8 @@ export function MemberFormDialog({
value={formData.part}
onChange={handleChange}
placeholder="frontend, backend, design, pm 등"
+ aria-invalid={errors.some((e) => e.includes('파트'))}
+ aria-describedby={errors.length > 0 ? 'form-errors' : undefined}
/>
@@ -230,6 +239,8 @@ export function MemberFormDialog({
value={formData.discordId}
onChange={handleChange}
placeholder="123456789012345678"
+ aria-invalid={errors.some((e) => e.includes('Discord ID'))}
+ aria-describedby={errors.length > 0 ? 'form-errors' : undefined}
/>
@@ -242,6 +253,7 @@ export function MemberFormDialog({
value={formData.discordUsername}
onChange={handleChange}
placeholder="username#1234"
+ aria-describedby={errors.length > 0 ? 'form-errors' : undefined}
/>
@@ -256,6 +268,8 @@ export function MemberFormDialog({
value={formData.blogUrl}
onChange={handleChange}
placeholder="https://velog.io/@username"
+ aria-invalid={errors.some((e) => e.includes('블로그 URL'))}
+ aria-describedby={errors.length > 0 ? 'form-errors' : undefined}
/>
diff --git a/packages/web/src/app/(admin)/admin/members/page.tsx b/packages/web/src/app/(admin)/admin/members/page.tsx
index 791da9c..d1612a7 100644
--- a/packages/web/src/app/(admin)/admin/members/page.tsx
+++ b/packages/web/src/app/(admin)/admin/members/page.tsx
@@ -18,6 +18,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
@@ -257,8 +258,12 @@ export default function AdminMembersPage() {
-
+
+
+
{error}
)}
diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx
index 648deb9..4bcd871 100644
--- a/packages/web/src/app/(user)/posts/page.tsx
+++ b/packages/web/src/app/(user)/posts/page.tsx
@@ -261,7 +261,10 @@ function PostsContent() {
{new Date(post.publishedAt).toLocaleDateString('ko-KR')}
-
+
+
+ (새 탭에서 열기)
+
))}
@@ -299,10 +302,12 @@ function PostsContent() {
href={post.url}
target="_blank"
rel="noopener noreferrer"
- className="text-sm text-primary hover:underline underline-offset-4 line-clamp-1 font-medium"
+ className="text-sm text-primary hover:underline underline-offset-4 line-clamp-1 font-medium inline-flex items-center gap-1"
onClick={() => trackPostView(post.id)}
>
{post.title}
+
+ (새 탭에서 열기)
@@ -333,6 +338,7 @@ function PostsContent() {
size="sm"
asChild
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
+ aria-label={`${post.title} (새 탭에서 열기)`}
>
@bbbang105
+
{' & '}
@choihooo
+
diff --git a/packages/web/src/components/layout/bottom-nav.tsx b/packages/web/src/components/layout/bottom-nav.tsx
index 38326cc..b9994f3 100644
--- a/packages/web/src/components/layout/bottom-nav.tsx
+++ b/packages/web/src/components/layout/bottom-nav.tsx
@@ -61,6 +61,7 @@ export function BottomNav({ isAdmin = false }: BottomNavProps) {
+
+ {label}
+ >
+ );
+}
diff --git a/packages/web/src/components/ui/index.ts b/packages/web/src/components/ui/index.ts
index 76d3677..d61fb37 100644
--- a/packages/web/src/components/ui/index.ts
+++ b/packages/web/src/components/ui/index.ts
@@ -6,3 +6,4 @@ export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableC
export { Separator } from './separator';
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
export { Badge, badgeVariants } from './badge';
+export { ExternalLinkIcon } from './external-link';
diff --git a/packages/web/src/components/ui/table.tsx b/packages/web/src/components/ui/table.tsx
index 4f14b5f..fa8148f 100644
--- a/packages/web/src/components/ui/table.tsx
+++ b/packages/web/src/components/ui/table.tsx
@@ -69,9 +69,10 @@ TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes
->(({ className, ...props }, ref) => (
+>(({ className, scope = 'col', ...props }, ref) => (
|