From 44ac941990f03b3d5a4c85207fc4ec2c6454f208 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:28:31 +0900 Subject: [PATCH 1/6] refactor: remove settings panels and inline all form fields - Delete ArticleSettings, MemberSettings, ProjectSettings components - Move slug, cover image, category, URLs inline into editors - Move author/visibility to header dropdown menus - Add delete option to "..." menu in headers - Remove competitive view counts from dashboard (Top Articles/Projects/Members) - Fix nested scrolling by using native browser scroll - leadMemberId dropdown always visible (not conditional on isNew) --- src/lib/components/ArticleForm.svelte | 52 +-- src/lib/components/MemberForm.svelte | 45 +-- src/lib/components/ProjectForm.svelte | 58 ++- .../admin-dashboard/AnalyticsBrief.svelte | 131 ------- .../article-form/ArticleEditor.svelte | 150 ++++++-- .../article-form/ArticleFormHeader.svelte | 127 +++++-- .../article-form/ArticleSettings.svelte | 335 ------------------ src/lib/components/article-form/index.ts | 1 - .../member-form/MemberEditor.svelte | 85 +++-- .../member-form/MemberFormHeader.svelte | 45 ++- .../member-form/MemberSettings.svelte | 102 ------ src/lib/components/member-form/index.ts | 1 - .../project-form/ProjectEditor.svelte | 136 +++++-- .../project-form/ProjectFormHeader.svelte | 129 +++++-- .../project-form/ProjectSettings.svelte | 191 ---------- src/routes/(admin)/admin/+page.svelte | 3 - .../admin/articles/edit/[id]/+page.svelte | 3 +- .../(admin)/admin/articles/new/+page.svelte | 2 +- .../admin/members/edit/[id]/+page.svelte | 3 +- .../(admin)/admin/members/new/+page.svelte | 2 +- .../admin/projects/edit/[id]/+page.svelte | 3 +- .../(admin)/admin/projects/new/+page.svelte | 2 +- 22 files changed, 567 insertions(+), 1039 deletions(-) delete mode 100644 src/lib/components/article-form/ArticleSettings.svelte delete mode 100644 src/lib/components/member-form/MemberSettings.svelte delete mode 100644 src/lib/components/project-form/ProjectSettings.svelte diff --git a/src/lib/components/ArticleForm.svelte b/src/lib/components/ArticleForm.svelte index d1a9ccf..c3fb7cb 100644 --- a/src/lib/components/ArticleForm.svelte +++ b/src/lib/components/ArticleForm.svelte @@ -5,7 +5,7 @@ import { triggerSubmit } from "$lib/utils/form"; import { onSaveShortcut } from "$lib/utils/keyboard"; import { snapshot } from "$lib/utils/snapshot.svelte"; - import { ArticleEditor, ArticleFormHeader, ArticleSettings } from "./article-form"; + import { ArticleEditor, ArticleFormHeader } from "./article-form"; import { confirm } from "$lib/components/confirm-modal.svelte"; let { @@ -23,7 +23,6 @@ submitLabel = "Save", isSubmitting = $bindable(false), articleId = null, - viewCount = 0, }: { initialData?: ArticleData; authors?: Author[]; @@ -32,12 +31,10 @@ submitLabel?: string; isSubmitting?: boolean; articleId?: string | null; - viewCount?: number; } = $props(); let formData = $state(snapshot(() => initialData)); let errors = $state>({}); - let showSettings = $state(false); let saveSuccess = $state(false); let createRedirect = $state(false); @@ -64,8 +61,6 @@ errors = validator.getErrors(); if (validator.hasErrors()) { - // Show settings panel if there are errors in settings fields - if (errors.slug) showSettings = true; return; } @@ -95,46 +90,33 @@ window.open(`/admin/articles/${articleId}/preview`, "_blank"); } } - triggerSubmit(handleSubmit, isSubmitting))} /> -
+ -
- (showSettings = true)} - /> - - -
+ diff --git a/src/lib/components/MemberForm.svelte b/src/lib/components/MemberForm.svelte index 5c113b4..edbc13d 100644 --- a/src/lib/components/MemberForm.svelte +++ b/src/lib/components/MemberForm.svelte @@ -5,7 +5,7 @@ import { triggerSubmit } from "$lib/utils/form"; import { onSaveShortcut } from "$lib/utils/keyboard"; import { snapshot } from "$lib/utils/snapshot.svelte"; - import { MemberEditor, MemberFormHeader, MemberSettings } from "./member-form"; + import { MemberEditor, MemberFormHeader } from "./member-form"; let { initialData = { slug: "", name: "", bio: "", imageUrl: "", pageContent: "" }, @@ -13,19 +13,16 @@ onDelete = null, submitLabel = "Save", isSubmitting = $bindable(false), - viewCount = 0, }: { initialData?: MemberData; onSubmit: (data: MemberData) => Promise; onDelete?: (() => Promise) | null; submitLabel?: string; isSubmitting?: boolean; - viewCount?: number; } = $props(); let formData = $state(snapshot(() => initialData)); let errors = $state>({}); - let showSettings = $state(false); function handleNameChange() { if (!formData.slug || formData.slug === generateSlug(initialData.name)) { @@ -47,7 +44,6 @@ errors = validator.getErrors(); if (validator.hasErrors()) { - if (errors.slug) showSettings = true; return; } @@ -62,32 +58,17 @@ triggerSubmit(handleSubmit, isSubmitting))} /> -
- - -
- (showSettings = true)} - /> + + - -
+ diff --git a/src/lib/components/ProjectForm.svelte b/src/lib/components/ProjectForm.svelte index 0a53a3b..a9c95fd 100644 --- a/src/lib/components/ProjectForm.svelte +++ b/src/lib/components/ProjectForm.svelte @@ -7,7 +7,6 @@ import { snapshot } from "$lib/utils/snapshot.svelte"; import ProjectEditor from "./project-form/ProjectEditor.svelte"; import ProjectFormHeader from "./project-form/ProjectFormHeader.svelte"; - import ProjectSettings from "./project-form/ProjectSettings.svelte"; let { initialData = { @@ -27,7 +26,6 @@ submitLabel = "Save", isSubmitting = $bindable(false), isNew = false, - viewCount = 0, }: { initialData?: ProjectData; members?: Member[]; @@ -36,12 +34,11 @@ submitLabel?: string; isSubmitting?: boolean; isNew?: boolean; - viewCount?: number; } = $props(); let formData = $state(snapshot(() => initialData)); let errors = $state>({}); - let showSettings = $state(false); + let saveSuccess = $state(false); function handleNameChange() { if (!formData.slug || formData.slug === generateSlug(initialData.name)) { @@ -62,19 +59,19 @@ if (isNew && !formData.leadMemberId) { validator.validate("leadMemberId", "", () => "Lead member is required"); - showSettings = true; } errors = validator.getErrors(); if (validator.hasErrors()) { - if (errors.slug || errors.leadMemberId) showSettings = true; return; } isSubmitting = true; + saveSuccess = false; try { await onSubmit(formData); + saveSuccess = true; } finally { isSubmitting = false; } @@ -83,41 +80,26 @@ triggerSubmit(handleSubmit, isSubmitting))} /> -
+ (showSettings = !showSettings)} + {onDelete} /> -
- (showSettings = true)} - /> - - (showSettings = false)} - {onDelete} - /> -
+ diff --git a/src/lib/components/admin-dashboard/AnalyticsBrief.svelte b/src/lib/components/admin-dashboard/AnalyticsBrief.svelte index e193b6b..d167ad8 100644 --- a/src/lib/components/admin-dashboard/AnalyticsBrief.svelte +++ b/src/lib/components/admin-dashboard/AnalyticsBrief.svelte @@ -2,25 +2,6 @@ import { ArrowRight, BarChart3, Eye, FileText, FolderKanban, Users } from "lucide-svelte"; import ViewTrendChart from "$lib/components/analytics/ViewTrendChart.svelte"; - interface TopItem { - id: string; - slug: string; - viewCount: number; - } - - interface TopArticle extends TopItem { - title: string; - author?: { name: string } | null; - } - - interface TopProject extends TopItem { - name: string; - } - - interface TopMember extends TopItem { - name: string; - } - interface ViewTrendData { date: string; count: number; @@ -31,9 +12,6 @@ totalArticleViews: number; totalProjectViews: number; totalMemberViews: number; - topArticles: TopArticle[]; - topProjects: TopProject[]; - topMembers: TopMember[]; viewTrend?: ViewTrendData[]; } @@ -42,25 +20,8 @@ totalArticleViews, totalProjectViews, totalMemberViews, - topArticles, - topProjects, - topMembers, viewTrend = [], }: Props = $props(); - - const topThreeArticles = $derived(topArticles.slice(0, 3)); - const topThreeProjects = $derived(topProjects.slice(0, 3)); - const topThreeMembers = $derived(topMembers.slice(0, 3)); - - const recentTrend = $derived(() => { - if (viewTrend.length < 2) return { value: 0, isPositive: true }; - const recent = viewTrend.slice(-7); - const older = viewTrend.slice(-14, -7); - const recentSum = recent.reduce((sum, d) => sum + d.count, 0); - const olderSum = older.reduce((sum, d) => sum + d.count, 0); - const change = olderSum === 0 ? 0 : ((recentSum - olderSum) / olderSum) * 100; - return { value: Math.abs(change), isPositive: change >= 0 }; - });
@@ -119,96 +80,4 @@ {/if} - -
- - {#if topThreeArticles.length > 0} - - {/if} - - - {#if topThreeProjects.length > 0} -
-

Top Projects

-
- {#each topThreeProjects as project, index (project.id)} - - - {index + 1} - -
-

{project.name}

-
-
- - {project.viewCount.toLocaleString()} -
-
- {/each} -
-
- {/if} - - - {#if topThreeMembers.length > 0} -
-

Top Member Pages

-
- {#each topThreeMembers as member, index (member.id)} - - - {index + 1} - -
-

{member.name}

-
-
- - {member.viewCount.toLocaleString()} -
-
- {/each} -
-
- {/if} -
diff --git a/src/lib/components/article-form/ArticleEditor.svelte b/src/lib/components/article-form/ArticleEditor.svelte index 3b580b7..f7ebbf2 100644 --- a/src/lib/components/article-form/ArticleEditor.svelte +++ b/src/lib/components/article-form/ArticleEditor.svelte @@ -1,28 +1,86 @@ -
+
- - {#if coverUrl} - - {/if} + 0} + class:border-red-300={displayError} + placeholder="title-slug" + /> +
- - {#if slug} - - {/if} + {#if displayError} +

{displayError}

+ {/if} + + + {#if initialSlug && slug !== initialSlug} + + {/if} + (null), + authors = [], isSubmitting = false, saveSuccess = $bindable(false), submitLabel = "Save", articleId = null, onPreview = null, + onDelete = null, }: { published?: boolean; - showSettings?: boolean; + authorId?: string | null; + authors?: Author[]; isSubmitting?: boolean; saveSuccess?: boolean; submitLabel?: string; articleId?: string | null; onPreview?: (() => void) | null; + onDelete?: (() => Promise) | null; } = $props(); let successTimeout: ReturnType | null = null; + let authorMenuOpen = $state(false); + let moreMenuOpen = $state(false); $effect(() => { if (saveSuccess) { @@ -53,6 +69,8 @@ onPreview(); } } + + const selectedAuthor = $derived(authors.find((a) => a.id === authorId)); @@ -172,8 +192,12 @@ -
- - - {#if migrationState?.status === "completed" || migrationState?.status === "error"} - @@ -271,8 +306,8 @@ id="logs" bind:this={logsContainer} class="h-80 overflow-auto rounded-lg bg-base-300 p-4 font-mono text-xs whitespace-pre-wrap">{#if migrationState?.logs.length}{migrationState.logs.join( - "\n", - )}{:else}No logs yet. Click "Start Migration" to begin.{/if} + "\n", + )}{:else}No logs yet. Click "Start Migration" to begin.{/if}
diff --git a/src/routes/api/migration/events/+server.ts b/src/routes/api/migration/events/+server.ts new file mode 100644 index 0000000..bcbc0ec --- /dev/null +++ b/src/routes/api/migration/events/+server.ts @@ -0,0 +1,79 @@ +/** + * SSE endpoint for real-time migration updates + * + * Clients connect via EventSource and receive state updates as they happen. + * Initial connection sends full state, subsequent updates send only new logs. + */ + +import { requireUtCodeMember } from "$lib/server/database/auth.server"; +import { migrationActor } from "$lib/server/services/migration/state.server"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ request }) => { + await requireUtCodeMember(); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + + function cleanup() { + if (closed) return; + closed = true; + clearInterval(heartbeat); + unsubscribe(); + try { + controller.close(); + } catch { + // Already closed + } + } + + // Send initial state + const initialState = migrationActor.getState(); + const initEvent = `event: init\ndata: ${JSON.stringify(initialState)}\n\n`; + controller.enqueue(encoder.encode(initEvent)); + + // Subscribe to updates + const unsubscribe = migrationActor.subscribe((state, newLogs) => { + if (closed) return; + try { + const update = { + status: state.status, + startedAt: state.startedAt, + completedAt: state.completedAt, + result: state.result, + error: state.error, + newLogs, + }; + const updateEvent = `event: update\ndata: ${JSON.stringify(update)}\n\n`; + controller.enqueue(encoder.encode(updateEvent)); + } catch { + cleanup(); + } + }); + + // Send heartbeat every 30s to keep connection alive + const heartbeat = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(":heartbeat\n\n")); + } catch { + cleanup(); + } + }, 30000); + + // Cleanup on abort + request.signal.addEventListener("abort", cleanup); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-store", + Connection: "keep-alive", + }, + }); +}; From 80a8d2df799327d2b1b372a9a61542911da92733 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:54:35 +0900 Subject: [PATCH 4/6] feat: add S3 cleanup on image change/remove - Fix S3KeySchema to match actual key format (folder/uuid-filename.ext) - Add removeByUrl command for URL-based S3 deletion - Delete old S3 files when images are changed or removed - Add extractS3KeyFromUrl helper function - Update tests for new key format --- docs/knowledges/image-upload.md | 30 ++++++++ src/lib/components/image-upload.svelte | 14 +++- src/lib/data/private/storage.remote.ts | 21 +++++- src/lib/shared/logic/storage.test.ts | 98 ++++++++++++++++++++------ src/lib/shared/logic/storage.ts | 31 ++++++-- 5 files changed, 164 insertions(+), 30 deletions(-) diff --git a/docs/knowledges/image-upload.md b/docs/knowledges/image-upload.md index 2ff48b5..9a71d5b 100644 --- a/docs/knowledges/image-upload.md +++ b/docs/knowledges/image-upload.md @@ -42,8 +42,38 @@ const MAX_BASE64_SIZE = Math.ceil(10 * 1024 * 1024 * 1.37); - Base64 adds ~37% overhead - 10MB file → ~13.7MB base64 +## S3 Key Format + +Keys follow the format: `{folder}/{uuid}-{filename}.{ext}` + +Example: `articles/a1b2c3d4-e5f6-7890-abcd-ef1234567890-cover.webp` + +Allowed folders: `images`, `uploads`, `covers`, `avatars`, `articles`, `members`, `projects` + +## S3 Cleanup + +When images are changed or removed, the old S3 file is automatically deleted: + +- **Change**: Old image deleted after new upload succeeds +- **Remove**: Image deleted immediately when "Remove" button clicked +- **External URLs**: Non-S3 URLs (different host) are ignored safely + +The `removeByUrl` command in `storage.remote.ts` handles URL-to-key conversion server-side. + +## User Input Methods + +The `ImageUpload` component supports: + +1. **Click**: Click to open file picker +2. **Drag & Drop**: Drag image files onto the component +3. **Paste (Ctrl+V)**: Paste from clipboard anywhere on the page + +Paste is handled globally via `svelte:window onpaste` and skips INPUT/TEXTAREA elements to avoid conflicts. + ## Design Decisions - **Never reject user uploads**: Compress instead of refusing - **Quality over size**: Try high quality first, only reduce if needed - **GIF exception**: GIFs skip compression (animation would be lost) +- **Clean up S3**: Delete old files on change/remove to avoid orphans +- **Fire-and-forget cleanup**: S3 deletion errors don't block the UI diff --git a/src/lib/components/image-upload.svelte b/src/lib/components/image-upload.svelte index 6791d91..43cc621 100644 --- a/src/lib/components/image-upload.svelte +++ b/src/lib/components/image-upload.svelte @@ -1,6 +1,6 @@