create branch dev and commit code
This commit is contained in:
parent
3b70cc9fe8
commit
ea68fa5cc4
44 changed files with 12421 additions and 214 deletions
442
src/routes/(authed)/tools/image-upload/+page.svelte
Normal file
442
src/routes/(authed)/tools/image-upload/+page.svelte
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
<script lang="ts">
|
||||
import { auth } from '$lib/core/stores/auth';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Select from '$lib/components/ui/select/index.js';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||
import Progress from '$lib/components/ui/progress/progress.svelte';
|
||||
import { Upload, X, ImageIcon, CheckCircle, AlertCircle } from '@lucide/svelte/icons';
|
||||
|
||||
const UPLOAD_PROXY_ENDPOINT = '/api/image-upload';
|
||||
|
||||
const ALLOWED_FOLDERS = [
|
||||
{ value: 'page_drink_picture2_n', label: 'page_drink_picture2_n' },
|
||||
{ value: 'page_drink_n', label: 'page_drink_n' },
|
||||
{ value: 'page_drink_disable_n2', label: 'page_drink_disable_n2' },
|
||||
{ value: 'page_drink_press', label: 'page_drink_press' },
|
||||
// { value: 'page_drink', label: 'page_drink' },
|
||||
// { value: 'page_drink_disable', label: 'page_drink_disable' },
|
||||
// { value: 'page_drink_disable_n', label: 'page_drink_disable_n' },
|
||||
// { value: 'page_drink_press_n', label: 'page_drink_press_n' },
|
||||
// { value: 'page_drink_select', label: 'page_drink_select' }
|
||||
];
|
||||
|
||||
const COUNTRIES = [
|
||||
{ value: 'tha', label: 'Thailand (tha)' },
|
||||
{ value: 'myn', label: 'Myanmar (myn)' },
|
||||
{ value: 'jpn', label: 'Japan (jpn)' },
|
||||
{ value: 'chn', label: 'China (chn)' },
|
||||
{ value: '', label: 'No Country (Global)' }
|
||||
];
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
file: File;
|
||||
preview: string;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let selectedCountry = $state('tha');
|
||||
let selectedFolder = $state('page_drink_picture2_n');
|
||||
let files = $state<FileItem[]>([]);
|
||||
let uploading = $state(false);
|
||||
let uploadProgress = $state({ current: 0, total: 0 });
|
||||
let dragOver = $state(false);
|
||||
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
}
|
||||
|
||||
function isValidFile(file: File): boolean {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) {
|
||||
addFiles(Array.from(input.files));
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
if (event.dataTransfer?.files) {
|
||||
addFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
function addFiles(newFiles: File[]) {
|
||||
const validFiles = newFiles.filter((file) => {
|
||||
if (!isValidFile(file)) {
|
||||
addNotification(`WARN:${file.name} - Invalid file type`);
|
||||
return false;
|
||||
}
|
||||
// Check for duplicates
|
||||
if (files.some((f) => f.file.name === file.name)) {
|
||||
addNotification(`WARN:${file.name} - Already added`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const newItems: FileItem[] = validFiles.map((file) => ({
|
||||
id: generateId(),
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
status: 'pending'
|
||||
}));
|
||||
|
||||
files = [...files, ...newItems];
|
||||
}
|
||||
|
||||
function removeFile(id: string) {
|
||||
const item = files.find((f) => f.id === id);
|
||||
if (item) {
|
||||
URL.revokeObjectURL(item.preview);
|
||||
}
|
||||
files = files.filter((f) => f.id !== id);
|
||||
}
|
||||
|
||||
function clearAllFiles() {
|
||||
files.forEach((f) => URL.revokeObjectURL(f.preview));
|
||||
files = [];
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
const currentUser = $auth;
|
||||
if (!currentUser) {
|
||||
addNotification('ERR:Not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingFiles = files.filter((f) => f.status === 'pending' || f.status === 'error');
|
||||
if (pendingFiles.length === 0) {
|
||||
addNotification('WARN:No files to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
uploadProgress = { current: 0, total: pendingFiles.length };
|
||||
|
||||
const uid = currentUser.uid;
|
||||
const displayName = currentUser.displayName || 'unknown';
|
||||
const email = currentUser.email || 'unknown@email.com';
|
||||
|
||||
for (let i = 0; i < pendingFiles.length; i++) {
|
||||
const item = pendingFiles[i];
|
||||
const index = files.findIndex((f) => f.id === item.id);
|
||||
if (index === -1) continue;
|
||||
|
||||
files[index].status = 'uploading';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('country', selectedCountry);
|
||||
formData.append('folder', selectedFolder);
|
||||
formData.append('uid', uid);
|
||||
formData.append('displayName', displayName);
|
||||
formData.append('email', email);
|
||||
formData.append('file', item.file);
|
||||
|
||||
const response = await fetch(UPLOAD_PROXY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(errorData.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
files[index].status = 'success';
|
||||
uploadProgress = { current: i + 1, total: pendingFiles.length };
|
||||
} catch (error) {
|
||||
files[index].status = 'error';
|
||||
files[index].error = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`Upload error for ${item.file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
|
||||
const successCount = files.filter((f) => f.status === 'success').length;
|
||||
const errorCount = files.filter((f) => f.status === 'error').length;
|
||||
|
||||
if (errorCount === 0) {
|
||||
addNotification(`INFO:Uploaded ${successCount} file(s) successfully`);
|
||||
} else {
|
||||
addNotification(`WARN:Uploaded ${successCount}, failed ${errorCount}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: FileItem['status']) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return CheckCircle;
|
||||
case 'error':
|
||||
return AlertCircle;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: FileItem['status']) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-green-500';
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
case 'uploading':
|
||||
return 'text-blue-500';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
files.forEach((f) => URL.revokeObjectURL(f.preview));
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 border-b bg-background">
|
||||
<div class="flex items-center justify-between px-8 py-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Image Upload</h1>
|
||||
<p class="text-sm text-muted-foreground">Upload menu images to the server</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{#if files.length > 0}
|
||||
<Button variant="outline" onclick={clearAllFiles} disabled={uploading}>
|
||||
<X class="mr-2 h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={uploadFiles} disabled={uploading || files.length === 0}>
|
||||
{#if uploading}
|
||||
<Spinner class="mr-2 h-4 w-4" />
|
||||
Uploading {uploadProgress.current}/{uploadProgress.total}...
|
||||
{:else}
|
||||
<Upload class="mr-2 h-4 w-4" />
|
||||
Upload ({files.filter((f) => f.status === 'pending' || f.status === 'error').length})
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-8">
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<!-- Settings -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Upload Settings</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Country</Label>
|
||||
<Select.Root type="single" bind:value={selectedCountry}>
|
||||
<Select.Trigger class="w-full">
|
||||
{COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Select country'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each COUNTRIES as country}
|
||||
<Select.Item value={country.value}>{country.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Folder</Label>
|
||||
<Select.Root type="single" bind:value={selectedFolder}>
|
||||
<Select.Trigger class="w-full">
|
||||
{ALLOWED_FOLDERS.find((f) => f.value === selectedFolder)?.label || 'Select folder'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each ALLOWED_FOLDERS as folder}
|
||||
<Select.Item value={folder.value}>{folder.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<!-- <p class="text-xs text-muted-foreground">
|
||||
Endpoint: <code class="rounded bg-muted px-1 py-0.5">
|
||||
{selectedCountry
|
||||
? `/inter/${selectedCountry}/image/${selectedFolder}/upload/...`
|
||||
: `/image/${selectedFolder}/upload/...`}
|
||||
</code>
|
||||
</p> -->
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<Card.Root>
|
||||
<Card.Content class="p-6">
|
||||
<label
|
||||
class="flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors {dragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||
class="hidden"
|
||||
onchange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<ImageIcon class="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p class="mb-2 text-lg font-medium">Drop images here or click to browse</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Supported formats: JPG, JPEG, PNG, GIF, WEBP
|
||||
</p>
|
||||
</label>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
{#if uploading}
|
||||
<div class="space-y-2">
|
||||
<Progress
|
||||
value={uploadProgress.total > 0
|
||||
? (uploadProgress.current / uploadProgress.total) * 100
|
||||
: 0}
|
||||
max={100}
|
||||
class="h-2"
|
||||
/>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
Uploading: {uploadProgress.current} / {uploadProgress.total}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center justify-between">
|
||||
<span>Selected Files ({files.length})</span>
|
||||
<div class="flex gap-2">
|
||||
{#if files.some((f) => f.status === 'success')}
|
||||
<Badge variant="default" class="bg-green-500">
|
||||
{files.filter((f) => f.status === 'success').length} uploaded
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if files.some((f) => f.status === 'error')}
|
||||
<Badge variant="destructive">
|
||||
{files.filter((f) => f.status === 'error').length} failed
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if files.some((f) => f.status === 'pending')}
|
||||
<Badge variant="secondary">
|
||||
{files.filter((f) => f.status === 'pending').length} pending
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{#each files as item (item.id)}
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-lg border bg-muted/30 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<!-- Preview Image -->
|
||||
<div class="aspect-square">
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={item.file.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Overlay for status -->
|
||||
{#if item.status === 'uploading'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||
>
|
||||
<Spinner class="h-8 w-8 text-white" />
|
||||
</div>
|
||||
{:else if item.status === 'success'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-green-500/20"
|
||||
>
|
||||
<CheckCircle class="h-10 w-10 text-green-500" />
|
||||
</div>
|
||||
{:else if item.status === 'error'}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-red-500/20"
|
||||
>
|
||||
<AlertCircle class="h-10 w-10 text-red-500" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remove button -->
|
||||
{#if item.status !== 'uploading'}
|
||||
<button
|
||||
class="absolute top-2 right-2 rounded-full bg-black/60 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onclick={() => removeFile(item.id)}
|
||||
disabled={uploading}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- File info -->
|
||||
<div class="p-2">
|
||||
<p
|
||||
class="truncate text-xs font-medium"
|
||||
title={item.file.name}
|
||||
>
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{(item.file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{#if item.error}
|
||||
<p class="truncate text-xs text-red-500" title={item.error}>
|
||||
{item.error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue