mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 08:26:41 -06:00
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:
@@ -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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user