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
46 changes: 41 additions & 5 deletions packages/web/src/app/(admin)/admin/curation/items/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +68,9 @@ export default function CurationItemsPage() {
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);

// Delete confirmation state
const [deleteTarget, setDeleteTarget] = useState<CurationItem | null>(null);

// Filters
const [category, setCategory] = useState('all');
const [sourceId, setSourceId] = useState('all');
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -206,7 +223,10 @@ export default function CurationItemsPage() {
className="font-medium text-sm hover:underline line-clamp-2 flex items-start gap-1 group"
>
<span className="flex-1">{item.title}</span>
<ExternalLink className="h-3.5 w-3.5 mt-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 mt-0.5 shrink-0">
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sr-only">(새 탭에서 열기)</span>
</span>
</a>

{/* Source + Date */}
Expand Down Expand Up @@ -280,6 +300,22 @@ export default function CurationItemsPage() {
</Button>
</div>
)}

{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>아이템 삭제</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{deleteTarget?.title}&rdquo; 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>삭제</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
54 changes: 44 additions & 10 deletions packages/web/src/app/(admin)/admin/curation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,6 +71,9 @@ export default function AdminCurationPage() {
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);

// Delete confirmation state
const [deleteTarget, setDeleteTarget] = useState<CurationSource | null>(null);

// Crawl modal state
const [crawlModalOpen, setCrawlModalOpen] = useState(false);
const [crawlStatus, setCrawlStatus] = useState<CrawlStatus>('idle');
Expand Down Expand Up @@ -185,24 +199,23 @@ 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',
});

if (!response.ok) {
throw new Error('Failed to delete source');
}

setDeleteTarget(null);
await fetchSources();
} catch (err) {
console.error('Error deleting source:', err);
Expand Down Expand Up @@ -361,6 +374,23 @@ export default function AdminCurationPage() {
errorMessage={crawlErrorMessage}
/>

{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>소스 삭제</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{deleteTarget?.name}&rdquo; 소스를 삭제하시겠습니까? 수집된 {deleteTarget?.itemCount}개의
아이템도 함께 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>삭제</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{/* Stats Cards */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 md:gap-4">
<Card>
Expand Down Expand Up @@ -443,8 +473,12 @@ export default function AdminCurationPage() {
<CardDescription>{filteredSources.length}개의 소스</CardDescription>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Label htmlFor="curation-search" className="sr-only">
소스 검색
</Label>
<Input
id="curation-search"
placeholder="검색..."
className="pl-8 w-full sm:w-[200px]"
value={searchQuery}
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/app/(admin)/admin/fines/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -301,8 +302,12 @@ export default function AdminFinesPage() {
</div>
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-auto">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Label htmlFor="fines-search" className="sr-only">
벌금 검색
</Label>
<Input
id="fines-search"
placeholder="검색..."
className="pl-8 w-full sm:w-[200px]"
value={searchQuery}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,12 @@ export function MemberFormDialog({
<form onSubmit={handleSubmit} className="space-y-4">
{/* Error messages */}
{errors.length > 0 && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div
role="alert"
aria-live="assertive"
id="form-errors"
className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"
>
{errors.map((error, index) => (
<p key={index}>{error}</p>
))}
Expand All @@ -202,6 +207,8 @@ export function MemberFormDialog({
value={formData.name}
onChange={handleChange}
placeholder="홍길동"
aria-invalid={errors.some((e) => e.includes('이름'))}
aria-describedby={errors.length > 0 ? 'form-errors' : undefined}
/>
</div>

Expand All @@ -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}
/>
</div>

Expand All @@ -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}
/>
</div>

Expand All @@ -242,6 +253,7 @@ export function MemberFormDialog({
value={formData.discordUsername}
onChange={handleChange}
placeholder="username#1234"
aria-describedby={errors.length > 0 ? 'form-errors' : undefined}
/>
</div>

Expand All @@ -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}
/>
</div>

Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/app/(admin)/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -257,8 +258,12 @@ export default function AdminMembersPage() {
</div>
<div className="flex items-center gap-2">
<div className="relative w-full sm:w-auto">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Label htmlFor="members-search" className="sr-only">
멤버 검색
</Label>
<Input
id="members-search"
placeholder="검색..."
className="pl-8 w-full sm:w-[200px]"
value={searchQuery}
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/app/(user)/board/write/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,12 @@ export default function BoardWritePage() {

{/* Error Message */}
{error && (
<p className="text-sm text-destructive text-center rounded-md bg-destructive/10 px-4 py-2.5 border border-destructive/20">
<p
role="alert"
aria-live="assertive"
id="form-error"
className="text-sm text-destructive text-center rounded-md bg-destructive/10 px-4 py-2.5 border border-destructive/20"
>
{error}
</p>
)}
Expand Down
10 changes: 8 additions & 2 deletions packages/web/src/app/(user)/posts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,10 @@ function PostsContent() {
<span>{new Date(post.publishedAt).toLocaleDateString('ko-KR')}</span>
</div>
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary shrink-0 mt-0.5 transition-colors" />
<span className="flex items-center gap-0.5 text-muted-foreground/40 group-hover:text-primary shrink-0 mt-0.5 transition-colors">
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">(새 탭에서 열기)</span>
</span>
</a>
))}
</div>
Expand Down Expand Up @@ -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}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
<span className="sr-only">(새 탭에서 열기)</span>
</a>
</TableCell>
<TableCell className="text-sm text-foreground/80 py-2.5 whitespace-nowrap">
Expand Down Expand Up @@ -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} (새 탭에서 열기)`}
>
<a
href={post.url}
Expand Down
7 changes: 5 additions & 2 deletions packages/web/src/components/landing/landing-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Trophy,
} from 'lucide-react';
import { CountUp, DrawLine, FadeUp, StaggerContainer, StaggerItem } from './motion';
import { ExternalLinkIcon } from '@/components/ui/external-link';

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -354,18 +355,20 @@ function Footer() {
href="https://github.com/bbbang105"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-500 hover:text-white transition-colors"
className="text-zinc-500 hover:text-white transition-colors inline-flex items-center gap-1"
>
@bbbang105
<ExternalLinkIcon />
</a>
{' & '}
<a
href="https://github.com/choihooo"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-500 hover:text-white transition-colors"
className="text-zinc-500 hover:text-white transition-colors inline-flex items-center gap-1"
>
@choihooo
<ExternalLinkIcon />
</a>
</p>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/components/layout/bottom-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function BottomNav({ isAdmin = false }: BottomNavProps) {
<Link
key={item.href}
href={item.href}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 py-1 text-[10px] font-medium transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground active:text-foreground'
Expand Down
Loading
Loading