mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 07:06:41 +02:00
- Add uload project with apps/web structure
- Reorganize from flat to monorepo structure
- Remove PocketBase binary and local data
- Update to pnpm and @uload/web namespace
- Add picture project to monorepo
- Remove embedded git repository
- Unify all package names to @{project}/{app} schema:
- @maerchenzauber/* (was @storyteller/*)
- @manacore/* (was manacore-*, manacore)
- @manadeck/* (was web, backend, manadeck)
- @memoro/* (was memoro-web, landing, memoro)
- @picture/* (already unified)
- @uload/web
- Add convenient dev scripts for all apps:
- pnpm dev:{project}:web
- pnpm dev:{project}:landing
- pnpm dev:{project}:mobile
- pnpm dev:{project}:backend
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
272 lines
8 KiB
Svelte
272 lines
8 KiB
Svelte
<script lang="ts">
|
|
import { validateImage } from '$lib/api/upload';
|
|
import type { UploadProgress } from '$lib/api/upload';
|
|
|
|
interface Props {
|
|
onFilesSelected: (files: File[]) => void;
|
|
uploading?: boolean;
|
|
uploadProgress?: UploadProgress[];
|
|
}
|
|
|
|
let { onFilesSelected, uploading = false, uploadProgress = [] }: Props = $props();
|
|
|
|
let isDragging = $state(false);
|
|
let fileInput: HTMLInputElement | null = $state(null);
|
|
let selectedFiles = $state<File[]>([]);
|
|
let previews = $state<{ file: File; url: string; error?: string }[]>([]);
|
|
|
|
function handleDragOver(e: DragEvent) {
|
|
e.preventDefault();
|
|
isDragging = true;
|
|
}
|
|
|
|
function handleDragLeave(e: DragEvent) {
|
|
e.preventDefault();
|
|
isDragging = false;
|
|
}
|
|
|
|
function handleDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
isDragging = false;
|
|
|
|
const files = Array.from(e.dataTransfer?.files || []);
|
|
handleFiles(files);
|
|
}
|
|
|
|
function handleFileInputChange(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const files = Array.from(input.files || []);
|
|
handleFiles(files);
|
|
}
|
|
|
|
function handleFiles(files: File[]) {
|
|
// Filter only image files
|
|
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
|
|
|
// Validate each file
|
|
const validatedFiles = imageFiles.map((file) => {
|
|
const validation = validateImage(file);
|
|
const url = URL.createObjectURL(file);
|
|
|
|
return {
|
|
file,
|
|
url,
|
|
error: validation.valid ? undefined : validation.error
|
|
};
|
|
});
|
|
|
|
previews = validatedFiles;
|
|
selectedFiles = validatedFiles.filter((f) => !f.error).map((f) => f.file);
|
|
}
|
|
|
|
function removeFile(index: number) {
|
|
URL.revokeObjectURL(previews[index].url);
|
|
previews = previews.filter((_, i) => i !== index);
|
|
selectedFiles = selectedFiles.filter((_, i) => i !== index);
|
|
}
|
|
|
|
function handleUpload() {
|
|
if (selectedFiles.length > 0) {
|
|
onFilesSelected(selectedFiles);
|
|
}
|
|
}
|
|
|
|
function clearAll() {
|
|
previews.forEach((p) => URL.revokeObjectURL(p.url));
|
|
previews = [];
|
|
selectedFiles = [];
|
|
if (fileInput) fileInput.value = '';
|
|
}
|
|
|
|
function getProgressForFile(filename: string): UploadProgress | undefined {
|
|
return uploadProgress.find((p) => p.filename === filename);
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
$effect(() => {
|
|
return () => {
|
|
previews.forEach((p) => URL.revokeObjectURL(p.url));
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Drop Zone -->
|
|
{#if !uploading && previews.length === 0}
|
|
<div
|
|
ondragover={handleDragOver}
|
|
ondragleave={handleDragLeave}
|
|
ondrop={handleDrop}
|
|
onclick={() => fileInput?.click()}
|
|
class="flex min-h-[400px] cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed p-12 transition-all {isDragging
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
|
|
: 'border-gray-300 bg-gray-50/50 hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:border-gray-600'}"
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
<svg
|
|
class="mb-4 h-16 w-16 {isDragging ? 'text-blue-500' : 'text-gray-400 dark:text-gray-600'}"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
|
|
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
|
|
{isDragging ? 'Loslassen zum Hochladen' : 'Bilder hochladen'}
|
|
</h3>
|
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
|
Ziehe deine Bilder hierher oder klicke zum Auswählen
|
|
</p>
|
|
<p class="text-xs text-gray-500 dark:text-gray-500">
|
|
JPG, PNG oder WebP • Max. 10MB pro Bild
|
|
</p>
|
|
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/jpeg,image/jpg,image/png,image/webp"
|
|
multiple
|
|
onchange={handleFileInputChange}
|
|
class="hidden"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Preview Grid -->
|
|
{#if previews.length > 0}
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{previews.length} {previews.length === 1 ? 'Bild' : 'Bilder'} ausgewählt
|
|
</h3>
|
|
{#if !uploading}
|
|
<button
|
|
onclick={clearAll}
|
|
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
|
>
|
|
Alle entfernen
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each previews as preview, index (preview.file.name)}
|
|
{@const progress = getProgressForFile(preview.file.name)}
|
|
<div
|
|
class="group relative overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
|
>
|
|
<!-- Image Preview -->
|
|
<div class="aspect-square w-full">
|
|
<img
|
|
src={preview.url}
|
|
alt={preview.file.name}
|
|
class="h-full w-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Overlay -->
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"
|
|
>
|
|
<!-- Remove Button (only when not uploading) -->
|
|
{#if !uploading}
|
|
<button
|
|
onclick={() => removeFile(index)}
|
|
class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-gray-900 opacity-0 transition-opacity hover:bg-white group-hover:opacity-100"
|
|
>
|
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- File Info -->
|
|
<div class="absolute bottom-0 left-0 right-0 p-3">
|
|
<p class="truncate text-sm font-medium text-white">
|
|
{preview.file.name}
|
|
</p>
|
|
<p class="text-xs text-white/80">
|
|
{(preview.file.size / 1024 / 1024).toFixed(2)} MB
|
|
</p>
|
|
|
|
<!-- Error -->
|
|
{#if preview.error}
|
|
<div class="mt-2 rounded bg-red-500/90 px-2 py-1 text-xs text-white">
|
|
{preview.error}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Progress -->
|
|
{#if progress}
|
|
<div class="mt-2 space-y-1">
|
|
<div class="flex items-center justify-between text-xs text-white">
|
|
<span>
|
|
{#if progress.status === 'uploading'}
|
|
Hochladen...
|
|
{:else if progress.status === 'success'}
|
|
✓ Fertig
|
|
{:else if progress.status === 'error'}
|
|
✗ Fehler
|
|
{:else}
|
|
Warten...
|
|
{/if}
|
|
</span>
|
|
{#if progress.status === 'uploading' || progress.status === 'success'}
|
|
<span>{Math.round(progress.progress)}%</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if progress.status === 'uploading' || progress.status === 'success'}
|
|
<div class="h-1 w-full overflow-hidden rounded-full bg-white/20">
|
|
<div
|
|
class="h-full bg-white transition-all duration-300 {progress.status === 'success' ? 'bg-green-500' : ''}"
|
|
style="width: {progress.progress}%"
|
|
></div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if progress.error}
|
|
<p class="text-xs text-red-300">{progress.error}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Upload Button -->
|
|
{#if !uploading && selectedFiles.length > 0}
|
|
<div class="flex justify-center pt-4">
|
|
<button
|
|
onclick={handleUpload}
|
|
class="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-base font-medium text-white transition-all hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
|
>
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
{selectedFiles.length} {selectedFiles.length === 1 ? 'Bild' : 'Bilder'} hochladen
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|