feat: add media upload pipeline with multipart support, blurhash generation, and R2 storage

- Add project_media table with image/video variants, ordering, and metadata
- Implement multipart upload handlers with 50MB limit
- Generate blurhash placeholders and resize images to thumb/medium/full variants
- Update ProjectCard to use media carousel instead of mock gradients
- Add MediaManager component for drag-drop upload and reordering
This commit is contained in:
2026-01-14 22:34:15 -06:00
parent 39a4e702fd
commit e83133cfcc
33 changed files with 3462 additions and 226 deletions
+6
View File
@@ -14,12 +14,14 @@
"@logtape/logtape": "^1.3.5",
"@resvg/resvg-js": "^2.6.2",
"@xevion/satori-html": "^0.4.1",
"blurhash": "^2.0.5",
"clsx": "^2.1.1",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-svelte": "^0.5.5",
"posthog-js": "^1.321.1",
"posthog-node": "^5.21.0",
"satori": "^0.18.3",
"svelte-dnd-action": "^0.9.69",
"tailwind-merge": "^3.3.1",
},
"devDependencies": {
@@ -412,6 +414,8 @@
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"blurhash": ["blurhash@2.0.5", "", {}, "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@@ -762,6 +766,8 @@
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-dnd-action": ["svelte-dnd-action@0.9.69", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
+2
View File
@@ -26,12 +26,14 @@
"@logtape/logtape": "^1.3.5",
"@resvg/resvg-js": "^2.6.2",
"@xevion/satori-html": "^0.4.1",
"blurhash": "^2.0.5",
"clsx": "^2.1.1",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-svelte": "^0.5.5",
"posthog-js": "^1.321.1",
"posthog-node": "^5.21.0",
"satori": "^0.18.3",
"svelte-dnd-action": "^0.9.69",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
+40
View File
@@ -19,6 +19,45 @@ export interface TagWithIcon extends AdminTag {
iconSvg?: string;
}
// Media types for project carousel
export type MediaType = "image" | "video";
export interface MediaVariant {
url: string;
width: number;
height: number;
}
export interface VideoOriginal {
url: string;
mime: string;
duration?: number;
}
export interface MediaVariants {
thumb?: MediaVariant;
medium?: MediaVariant;
full?: MediaVariant;
original?: MediaVariant;
poster?: MediaVariant;
video?: VideoOriginal;
}
export interface MediaMetadata {
focalPoint?: { x: number; y: number };
altText?: string;
duration?: number;
}
export interface ProjectMedia {
id: string;
displayOrder: number;
mediaType: MediaType;
variants: MediaVariants;
blurhash?: string;
metadata?: MediaMetadata;
}
export interface AdminProject {
id: string;
slug: string;
@@ -28,6 +67,7 @@ export interface AdminProject {
status: ProjectStatus;
links: Array<{ url: string; title?: string }>;
tags: AdminTag[];
media: ProjectMedia[];
githubRepo?: string | null;
demoUrl?: string | null;
createdAt: string;
+63
View File
@@ -9,6 +9,7 @@ import type {
CreateTagData,
UpdateTagData,
SiteSettings,
ProjectMedia,
} from "./admin-types";
import { ApiError } from "./errors";
@@ -134,6 +135,68 @@ export async function getRelatedTags(slug: string): Promise<RelatedTag[]> {
return clientApiFetch<RelatedTag[]>(`/api/tags/${slug}/related`);
}
// Admin Media API
export async function uploadProjectMedia(
projectId: string,
file: File,
onProgress?: (percent: number) => void,
): Promise<ProjectMedia> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
resolve(data as ProjectMedia);
} catch {
reject(new Error("Invalid response from server"));
}
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.open("POST", `/api/projects/${projectId}/media`);
xhr.withCredentials = true;
xhr.send(formData);
});
}
export async function deleteProjectMedia(
projectId: string,
mediaId: string,
): Promise<ProjectMedia> {
return clientApiFetch<ProjectMedia>(
`/api/projects/${projectId}/media/${mediaId}`,
{ method: "DELETE" },
);
}
export async function reorderProjectMedia(
projectId: string,
mediaIds: string[],
): Promise<ProjectMedia[]> {
return clientApiFetch<ProjectMedia[]>(
`/api/projects/${projectId}/media/reorder`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mediaIds }),
},
);
}
// Admin Events API (currently mocked - no backend implementation yet)
export async function getAdminEvents(): Promise<AdminEvent[]> {
// TODO: Implement when events table is added to backend
+45 -60
View File
@@ -30,44 +30,28 @@
project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null,
);
// Random seed generated once per component instance (changes on each page load)
const randomSeed = Math.floor(Math.random() * 1000);
// Get primary media (first by display order) if available
const primaryMedia = $derived(project.media?.[0]);
const hasMedia = $derived(!!primaryMedia);
const isVideo = $derived(primaryMedia?.mediaType === "video");
// Randomly decide if this card shows video or image (~60% video)
const isVideo = randomSeed % 10 < 6;
// Get media URLs from primary media
const videoUrl = $derived(
isVideo ? primaryMedia?.variants.video?.url : undefined,
);
// Sample video URLs
const sampleVideos = [
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4",
"https://storage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/samples/elephants.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/samples/sea-turtle.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/dog.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/ski_jump.mp4",
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/snow_horses.mp4",
];
const imageUrl = $derived(
!isVideo && primaryMedia
? (primaryMedia.variants.medium?.url ??
primaryMedia.variants.thumb?.url ??
primaryMedia.variants.full?.url)
: undefined,
);
const videoUrl = sampleVideos[randomSeed % sampleVideos.length];
// Randomized aspect ratios for images: [width, height]
const aspectRatios: [number, number][] = [
[400, 300], // 4:3 landscape
[300, 400], // 3:4 portrait
[400, 400], // 1:1 square
[480, 270], // 16:9 landscape
[270, 480], // 9:16 portrait
[400, 240], // 5:3 wide landscape
[240, 400], // 3:5 tall portrait
];
const aspectIndex = randomSeed % aspectRatios.length;
const [imgWidth, imgHeight] = aspectRatios[aspectIndex];
const imageUrl = `https://picsum.photos/seed/${randomSeed}/${imgWidth}/${imgHeight}`;
// Video poster URL (for video media, use the poster variant)
const videoPosterUrl = $derived(
isVideo ? primaryMedia?.variants.poster?.url : undefined,
);
// Video element reference for play/pause control
let videoElement: HTMLVideoElement | null = $state(null);
@@ -131,34 +115,35 @@
role={isLink ? undefined : "article"}
class={cn(
"group relative flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 overflow-hidden",
{
"transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50":
isLink,
className: true,
},
isLink &&
"transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50",
className,
)}
>
<!-- Background media layer -->
<div
class="pointer-events-none absolute inset-0 opacity-25 group-hover:opacity-40"
style="transition: opacity 300ms ease-in-out;"
aria-hidden="true"
>
{#if isVideo}
<video
bind:this={videoElement}
src={videoUrl}
class={cn(mediaBaseClasses, "grayscale group-hover:grayscale-0")}
style="transition: filter 300ms ease-in-out;"
muted
loop
playsinline
preload="metadata"
></video>
{:else}
<img src={imageUrl} alt="" class={mediaBaseClasses} loading="lazy" />
{/if}
</div>
{#if hasMedia}
<div
class="pointer-events-none absolute inset-0 opacity-25 group-hover:opacity-40"
style="transition: opacity 300ms ease-in-out;"
aria-hidden="true"
>
{#if isVideo && videoUrl}
<video
bind:this={videoElement}
src={videoUrl}
poster={videoPosterUrl}
class={cn(mediaBaseClasses, "grayscale group-hover:grayscale-0")}
style="transition: filter 300ms ease-in-out;"
muted
loop
playsinline
preload="metadata"
></video>
{:else if imageUrl}
<img src={imageUrl} alt="" class={mediaBaseClasses} loading="lazy" />
{/if}
</div>
{/if}
<!-- Content layer -->
<div
@@ -0,0 +1,118 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { decode } from "blurhash";
import type { ProjectMedia } from "$lib/admin-types";
import VideoThumbnail from "./VideoThumbnail.svelte";
import IconX from "~icons/lucide/x";
import IconPlay from "~icons/lucide/play";
import IconFilm from "~icons/lucide/film";
import IconImage from "~icons/lucide/image";
interface Props {
media: ProjectMedia;
ondelete: () => void;
class?: string;
}
let { media, ondelete, class: className }: Props = $props();
// Get the best thumbnail URL (for images)
const thumbUrl = $derived(
media.variants.thumb?.url ??
media.variants.medium?.url ??
media.variants.full?.url ??
media.variants.poster?.url,
);
// Get video URL (for videos)
const videoUrl = $derived(media.variants.video?.url);
// Decode blurhash to canvas on mount
let canvasRef: HTMLCanvasElement | null = $state(null);
let imageLoaded = $state(false);
$effect(() => {
if (canvasRef && media.blurhash && !imageLoaded) {
try {
const pixels = decode(media.blurhash, 32, 32);
const ctx = canvasRef.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
} catch {
// Silently fail if blurhash is invalid
}
}
});
function handleImageLoad() {
imageLoaded = true;
}
</script>
<!-- Outer wrapper allows delete button to escape bounds -->
<div class={cn("group relative", className)}>
<!-- Media container with fixed height -->
<div
class="relative h-28 rounded-lg border border-admin-border bg-admin-bg-secondary overflow-hidden"
>
<!-- Blurhash placeholder -->
{#if media.blurhash && !imageLoaded}
<canvas
bind:this={canvasRef}
width="32"
height="32"
class="absolute inset-0 w-full h-full object-cover"
></canvas>
{/if}
<!-- Actual thumbnail or video -->
{#if media.mediaType === "video" && videoUrl}
<!-- Video thumbnail - capture first frame to canvas -->
<VideoThumbnail src={videoUrl} onload={handleImageLoad} />
{:else if thumbUrl}
<img
src={thumbUrl}
alt=""
class={cn(
"absolute inset-0 w-full h-full object-cover transition-opacity duration-200",
imageLoaded ? "opacity-100" : "opacity-0",
)}
onload={handleImageLoad}
/>
{:else}
<!-- Fallback for missing thumbnail -->
<div
class="absolute inset-0 flex items-center justify-center text-admin-text-muted"
>
{#if media.mediaType === "video"}
<IconFilm class="size-6" />
{:else}
<IconImage class="size-6" />
{/if}
</div>
{/if}
<!-- Video badge -->
{#if media.mediaType === "video"}
<div
class="absolute top-2 left-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded flex items-center gap-1"
>
<IconPlay class="size-2.5" />
<span>Video</span>
</div>
{/if}
</div>
<!-- Delete button - positioned outside the overflow-hidden container -->
<button
type="button"
onclick={ondelete}
class="absolute -top-2 -right-2 w-6 h-6 bg-red-600 hover:bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-md z-10"
aria-label="Delete media"
>
<IconX class="size-3.5" />
</button>
</div>
@@ -0,0 +1,395 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
import type { ProjectMedia } from "$lib/admin-types";
import {
uploadProjectMedia,
deleteProjectMedia,
reorderProjectMedia,
} from "$lib/api";
import MediaItem from "./MediaItem.svelte";
import Modal from "./Modal.svelte";
import { getLogger } from "@logtape/logtape";
import IconCloudUpload from "~icons/lucide/cloud-upload";
import IconAlertCircle from "~icons/lucide/alert-circle";
import IconLoader from "~icons/lucide/loader-2";
import IconX from "~icons/lucide/x";
const logger = getLogger(["admin", "components", "MediaManager"]);
interface Props {
projectId: string | null;
media?: ProjectMedia[];
onchange?: (media: ProjectMedia[]) => void;
class?: string;
}
let { projectId, media = [], onchange, class: className }: Props = $props();
// Local media state (for reordering) - needs to be mutable for drag-drop
// eslint-disable-next-line svelte/prefer-writable-derived -- intentional: svelte-dnd-action requires mutable array
let mediaItems = $state<ProjectMedia[]>([]);
// Sync from props when they change
$effect(() => {
mediaItems = [...media];
});
// Upload state
interface UploadTask {
id: string;
file: File;
progress: number;
status: "uploading" | "done" | "error";
error?: string;
}
let uploadQueue = $state<UploadTask[]>([]);
// UI state
let isDraggingFile = $state(false);
let errorMessage = $state<string | null>(null);
let fileInputRef: HTMLInputElement | null = $state(null);
// Delete confirmation
let deleteModalOpen = $state(false);
let deletingMedia = $state<ProjectMedia | null>(null);
const flipDurationMs = 150;
const SUPPORTED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
];
const SUPPORTED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/quicktime"];
const SUPPORTED_TYPES = [...SUPPORTED_IMAGE_TYPES, ...SUPPORTED_VIDEO_TYPES];
// Drag and drop reorder handlers
function handleDndConsider(e: CustomEvent<{ items: ProjectMedia[] }>) {
mediaItems = e.detail.items;
}
async function handleDndFinalize(e: CustomEvent<{ items: ProjectMedia[] }>) {
mediaItems = e.detail.items;
onchange?.(mediaItems);
// Call reorder API
if (projectId) {
try {
await reorderProjectMedia(
projectId,
mediaItems.map((m) => m.id),
);
logger.info("Media reordered", { projectId, count: mediaItems.length });
} catch (err) {
logger.error("Failed to reorder media", { error: err });
showError("Failed to save new order");
}
}
}
// File upload handlers
function handleDragEnter(e: DragEvent) {
e.preventDefault();
isDraggingFile = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
// Only set false if leaving the drop zone entirely
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
isDraggingFile = false;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDraggingFile = true;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDraggingFile = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFiles(Array.from(files));
}
}
function handleFileInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(Array.from(input.files));
input.value = ""; // Reset for re-upload of same file
}
}
function handleFiles(files: File[]) {
clearError();
const validFiles: File[] = [];
const invalidTypes: string[] = [];
for (const file of files) {
if (SUPPORTED_TYPES.includes(file.type)) {
validFiles.push(file);
} else {
invalidTypes.push(file.name);
}
}
if (invalidTypes.length > 0) {
showError(
`Unsupported file type${invalidTypes.length > 1 ? "s" : ""}: ${invalidTypes.join(", ")}`,
);
}
for (const file of validFiles) {
uploadFile(file);
}
}
async function uploadFile(file: File) {
if (!projectId) return;
const taskId = crypto.randomUUID();
const task: UploadTask = {
id: taskId,
file,
progress: 0,
status: "uploading",
};
uploadQueue = [...uploadQueue, task];
try {
const media = await uploadProjectMedia(projectId, file, (progress) => {
uploadQueue = uploadQueue.map((t) =>
t.id === taskId ? { ...t, progress } : t,
);
});
// Add to media items
mediaItems = [...mediaItems, media];
onchange?.(mediaItems);
// Remove from queue
uploadQueue = uploadQueue.filter((t) => t.id !== taskId);
logger.info("Media uploaded", { projectId, mediaId: media.id });
} catch (err) {
logger.error("Upload failed", { error: err, filename: file.name });
uploadQueue = uploadQueue.map((t) =>
t.id === taskId ? { ...t, status: "error", error: String(err) } : t,
);
}
}
function removeUploadTask(taskId: string) {
uploadQueue = uploadQueue.filter((t) => t.id !== taskId);
}
// Delete handlers
function handleDeleteClick(media: ProjectMedia) {
deletingMedia = media;
deleteModalOpen = true;
}
async function confirmDelete() {
if (!projectId || !deletingMedia) return;
try {
await deleteProjectMedia(projectId, deletingMedia.id);
mediaItems = mediaItems.filter((m) => m.id !== deletingMedia!.id);
onchange?.(mediaItems);
logger.info("Media deleted", { projectId, mediaId: deletingMedia.id });
} catch (err) {
logger.error("Failed to delete media", { error: err });
showError("Failed to delete media");
}
deletingMedia = null;
}
// Error handling
function showError(msg: string) {
errorMessage = msg;
setTimeout(() => {
if (errorMessage === msg) {
errorMessage = null;
}
}, 5000);
}
function clearError() {
errorMessage = null;
}
</script>
<div class={cn("space-y-1.5", className)}>
<div class="block text-sm font-medium text-admin-text">Media</div>
{#if !projectId}
<!-- Disabled state for new projects -->
<div
class="rounded-lg border-2 border-dashed border-admin-border bg-admin-bg-secondary p-8 text-center"
>
<IconCloudUpload class="size-8 text-admin-text-muted mb-2 mx-auto" />
<p class="text-sm text-admin-text-muted">
Save the project first to enable media uploads
</p>
</div>
{:else}
<!-- Media grid (if has media) -->
{#if mediaItems.length > 0}
<div
use:dndzone={{
items: mediaItems,
flipDurationMs,
dropTargetStyle: {},
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-3"
>
{#each mediaItems as item (item.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<MediaItem media={item} ondelete={() => handleDeleteClick(item)} />
</div>
{/each}
</div>
{/if}
<!-- Upload drop zone -->
<div
role="button"
tabindex="0"
class={cn(
"rounded-lg border-2 border-dashed p-6 text-center cursor-pointer transition-colors",
isDraggingFile
? "border-admin-accent bg-admin-accent/10"
: "border-admin-border bg-admin-bg-secondary hover:border-admin-text-muted hover:bg-admin-surface",
)}
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}
onclick={() => fileInputRef?.click()}
onkeydown={(e) => e.key === "Enter" && fileInputRef?.click()}
>
<IconCloudUpload
class={cn(
"size-6 mb-2 mx-auto",
isDraggingFile ? "text-admin-accent" : "text-admin-text-muted",
)}
/>
<p class="text-sm text-admin-text">
{isDraggingFile
? "Drop files here"
: "Drop files here or click to upload"}
</p>
<p class="text-xs text-admin-text-muted mt-1">
JPEG, PNG, GIF, WebP, MP4, WebM
</p>
</div>
<input
bind:this={fileInputRef}
type="file"
accept={SUPPORTED_TYPES.join(",")}
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<!-- Upload queue -->
{#if uploadQueue.length > 0}
<div class="space-y-2 mt-3">
{#each uploadQueue as task (task.id)}
<div
class="flex items-center gap-3 p-2 rounded-lg bg-admin-bg-secondary border border-admin-border"
>
{#if task.status === "error"}
<IconAlertCircle class="size-4 text-red-500 shrink-0" />
{:else}
<IconLoader
class="size-4 text-admin-text-muted animate-spin shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<p class="text-sm text-admin-text truncate">{task.file.name}</p>
{#if task.status === "uploading"}
<div
class="h-1.5 bg-admin-border rounded-full mt-1 overflow-hidden"
>
<div
class="h-full bg-admin-accent transition-all duration-200"
style="width: {task.progress}%"
></div>
</div>
{:else if task.status === "error"}
<p class="text-xs text-red-500 truncate">{task.error}</p>
{/if}
</div>
{#if task.status === "error"}
<button
type="button"
onclick={() => removeUploadTask(task.id)}
class="text-admin-text-muted hover:text-admin-text p-1"
aria-label="Dismiss error"
>
<IconX class="size-4" />
</button>
{:else}
<span class="text-xs text-admin-text-muted">{task.progress}%</span
>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Error message -->
{#if errorMessage}
<div
class="flex items-center gap-2 p-3 mt-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm"
>
<IconAlertCircle class="size-4 shrink-0" />
<span>{errorMessage}</span>
<button
type="button"
onclick={clearError}
class="ml-auto hover:text-red-400"
aria-label="Dismiss error"
>
<IconX class="size-4" />
</button>
</div>
{/if}
{/if}
<p class="text-xs text-admin-text-muted">
{#if projectId}
Drag to reorder. First image is shown as the project thumbnail.
{:else}
Media can be uploaded after saving the project.
{/if}
</p>
</div>
<!-- Delete confirmation modal -->
<Modal
bind:open={deleteModalOpen}
title="Delete Media"
description="Are you sure you want to delete this media? This action cannot be undone."
confirmText="Delete"
confirmVariant="danger"
onconfirm={confirmDelete}
oncancel={() => (deletingMedia = null)}
/>
@@ -2,6 +2,7 @@
import Button from "./Button.svelte";
import Input from "./Input.svelte";
import TagPicker from "./TagPicker.svelte";
import MediaManager from "./MediaManager.svelte";
import type {
AdminProject,
CreateProjectData,
@@ -177,17 +178,8 @@
placeholder="Search and select tags..."
/>
<!-- Media Upload Placeholder -->
<div class="space-y-1.5">
<div class="block text-sm font-medium text-admin-text">Media</div>
<Button type="button" variant="secondary" disabled class="w-full">
<i class="fa-solid fa-upload mr-2"></i>
Upload Images/Videos (Coming Soon)
</Button>
<p class="text-xs text-admin-text-muted">
Media upload functionality will be available soon
</p>
</div>
<!-- Media -->
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
@@ -0,0 +1,60 @@
<script lang="ts">
interface Props {
src: string;
onload?: () => void;
class?: string;
}
let { src, onload, class: className }: Props = $props();
let canvasRef: HTMLCanvasElement | null = $state(null);
let loaded = $state(false);
$effect(() => {
if (!canvasRef || !src || loaded) return;
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
video.onloadeddata = () => {
// Seek to 0.1s to avoid black frames
video.currentTime = 0.1;
};
video.onseeked = () => {
if (!canvasRef) return;
const ctx = canvasRef.getContext("2d");
if (ctx) {
// Set canvas size to match video
canvasRef.width = video.videoWidth;
canvasRef.height = video.videoHeight;
// Draw the frame
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
loaded = true;
onload?.();
}
// Clean up
video.src = "";
video.load();
};
video.onerror = () => {
// Still call onload to remove loading state
loaded = true;
onload?.();
};
video.src = src;
});
</script>
<canvas
bind:this={canvasRef}
class={className ?? "absolute inset-0 w-full h-full object-cover"}
></canvas>
-98
View File
@@ -1,98 +0,0 @@
export interface MockProjectTag {
name: string;
icon: string; // Icon identifier like "simple-icons:rust"
color?: string; // Hex color without hash
iconSvg?: string; // Pre-rendered SVG (populated server-side)
}
export interface MockProject {
id: string;
name: string;
description: string;
url: string;
tags: MockProjectTag[];
lastActivity: string;
clockIconSvg?: string; // Pre-rendered clock icon for "Updated" text
}
export const MOCK_PROJECTS: MockProject[] = [
{
id: "1",
name: "xevion.dev",
description:
"Personal portfolio showcasing projects and technical expertise. Built with Rust backend, SvelteKit frontend, and PostgreSQL.",
url: "https://github.com/Xevion/xevion.dev",
tags: [
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "SvelteKit", icon: "simple-icons:svelte", color: "f43f5e" },
{ name: "PostgreSQL", icon: "cib:postgresql", color: "3b82f6" },
],
lastActivity: "2026-01-06T22:12:37Z",
},
{
id: "2",
name: "historee",
description:
"Powerful browser history analyzer for visualizing and understanding web browsing patterns across multiple browsers.",
url: "https://github.com/Xevion/historee",
tags: [
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "CLI", icon: "lucide:terminal", color: "a1a1aa" },
{ name: "Analytics", icon: "lucide:bar-chart-3", color: "10b981" },
],
lastActivity: "2026-01-06T06:01:27Z",
},
{
id: "3",
name: "satori-html",
description:
"HTML adapter for Vercel's Satori library, enabling generation of beautiful social card images from HTML markup.",
url: "https://github.com/Xevion/satori-html",
tags: [
{ name: "TypeScript", icon: "simple-icons:typescript", color: "3b82f6" },
{ name: "NPM", icon: "simple-icons:npm", color: "ec4899" },
{ name: "Graphics", icon: "lucide:image", color: "a855f7" },
],
lastActivity: "2026-01-05T20:23:07Z",
},
{
id: "4",
name: "byte-me",
description:
"Cross-platform media bitrate visualizer with real-time analysis. Built with Tauri for native performance and modern UI.",
url: "https://github.com/Xevion/byte-me",
tags: [
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "Tauri", icon: "simple-icons:tauri", color: "14b8a6" },
{ name: "Desktop", icon: "lucide:monitor", color: "6366f1" },
{ name: "Media", icon: "lucide:video", color: "f43f5e" },
],
lastActivity: "2026-01-05T05:09:09Z",
},
{
id: "5",
name: "rdap",
description:
"Modern RDAP query client for domain registration data lookup. Clean interface built with static Next.js for instant loads.",
url: "https://github.com/Xevion/rdap",
tags: [
{ name: "TypeScript", icon: "simple-icons:typescript", color: "3b82f6" },
{ name: "Next.js", icon: "simple-icons:nextdotjs", color: "a1a1aa" },
{ name: "Networking", icon: "lucide:network", color: "0ea5e9" },
],
lastActivity: "2026-01-05T10:36:55Z",
},
{
id: "6",
name: "rebinded",
description:
"Cross-platform key remapping daemon with per-application context awareness and intelligent stateful debouncing.",
url: "https://github.com/Xevion/rebinded",
tags: [
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
{ name: "System", icon: "lucide:settings-2", color: "a1a1aa" },
{ name: "Cross-platform", icon: "lucide:globe", color: "22c55e" },
],
lastActivity: "2026-01-01T00:34:09Z",
},
];