create branch dev and commit code

This commit is contained in:
thanawat saiyota 2026-06-09 10:50:59 +07:00
parent 3b70cc9fe8
commit ea68fa5cc4
44 changed files with 12421 additions and 214 deletions

View 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>