Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { PillDropdown } from '@manacore/shared-ui';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
</script>
<PillDropdown items={languageItems} label={currentLabel} direction="down" />

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import type { Toast } from '$lib/stores/toast';
import { fly } from 'svelte/transition';
let toasts = $state<Toast[]>([]);
toast.subscribe((value) => {
toasts = value;
});
function handleClose(id: string) {
toast.remove(id);
}
function getIcon(type: Toast['type']) {
switch (type) {
case 'success':
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>`;
case 'error':
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>`;
case 'warning':
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>`;
case 'info':
default:
return `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>`;
}
}
</script>
<div class="toast-container">
{#each toasts as toastItem (toastItem.id)}
<div
class="toast toast-{toastItem.type}"
transition:fly={{ y: 20, duration: 300 }}
role="alert"
>
<div class="toast-icon">
{@html getIcon(toastItem.type)}
</div>
<p class="toast-message">{toastItem.message}</p>
<button
class="toast-close"
onclick={() => handleClose(toastItem.id)}
aria-label="Close notification"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/each}
</div>
<style>
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: rgb(var(--color-surface-elevated));
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid rgb(var(--color-border));
min-width: 300px;
max-width: 400px;
pointer-events: auto;
backdrop-filter: blur(10px);
}
.toast-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.toast-success {
border-left: 4px solid rgb(var(--color-success));
}
.toast-success .toast-icon {
color: rgb(var(--color-success));
}
.toast-error {
border-left: 4px solid rgb(var(--color-error));
}
.toast-error .toast-icon {
color: rgb(var(--color-error));
}
.toast-warning {
border-left: 4px solid rgb(var(--color-warning));
}
.toast-warning .toast-icon {
color: rgb(var(--color-warning));
}
.toast-info {
border-left: 4px solid rgb(var(--color-info));
}
.toast-info .toast-icon {
color: rgb(var(--color-info));
}
.toast-message {
flex: 1;
margin: 0;
font-size: 0.9375rem;
color: rgb(var(--color-text-primary));
line-height: 1.5;
}
.toast-close {
flex-shrink: 0;
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-secondary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.toast-close:hover {
background: rgba(var(--color-border), 0.5);
color: rgb(var(--color-text-primary));
}
@media (max-width: 768px) {
.toast-container {
bottom: 6rem;
right: 1rem;
left: 1rem;
align-items: stretch;
}
.toast {
min-width: auto;
max-width: none;
}
.toast-message {
font-size: 0.875rem;
}
}
</style>

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { ChevronRight, Home } from 'lucide-svelte';
interface BreadcrumbItem {
id: string | null;
name: string;
}
interface Props {
items: BreadcrumbItem[];
onNavigate: (id: string | null) => void;
}
let { items, onNavigate }: Props = $props();
</script>
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list">
<li class="breadcrumb-item">
<button onclick={() => onNavigate(null)} class="breadcrumb-link" aria-label="Home">
<Home size={16} />
<span>Meine Dateien</span>
</button>
</li>
{#each items as item, index (item.id)}
<li class="breadcrumb-item">
<ChevronRight size={16} class="separator" />
{#if index === items.length - 1}
<span class="breadcrumb-current">{item.name}</span>
{:else}
<button onclick={() => onNavigate(item.id)} class="breadcrumb-link">
{item.name}
</button>
{/if}
</li>
{/each}
</ol>
</nav>
<style>
.breadcrumb {
margin-bottom: 1.5rem;
}
.breadcrumb-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.breadcrumb-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.breadcrumb-link:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.breadcrumb-current {
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
padding: 0.25rem 0.5rem;
}
.breadcrumb-item :global(.separator) {
color: rgb(var(--color-text-tertiary));
}
</style>

View file

@ -0,0 +1,202 @@
<script lang="ts">
import type { StorageFile } from '$lib/api/client';
import {
File,
FileImage,
FileText,
FileVideo,
FileAudio,
FileArchive,
Heart,
MoreVertical,
} from 'lucide-svelte';
interface Props {
file: StorageFile;
onClick?: () => void;
onAction?: (action: string) => void;
}
let { file, onClick, onAction }: Props = $props();
let showMenu = $state(false);
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return FileImage;
if (mimeType.startsWith('video/')) return FileVideo;
if (mimeType.startsWith('audio/')) return FileAudio;
if (mimeType.startsWith('text/')) return FileText;
if (mimeType.includes('zip') || mimeType.includes('archive')) return FileArchive;
return File;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
showMenu = !showMenu;
}
function handleAction(action: string) {
showMenu = false;
onAction?.(action);
}
const Icon = getFileIcon(file.mimeType);
</script>
<div class="file-card" onclick={onClick} role="button" tabindex="0">
<div class="file-icon">
<Icon size={40} strokeWidth={1.5} />
{#if file.isFavorite}
<div class="favorite-badge">
<Heart size={12} fill="currentColor" />
</div>
{/if}
</div>
<div class="file-info">
<span class="file-name" title={file.name}>{file.name}</span>
<span class="file-size">{formatFileSize(file.size)}</span>
</div>
<button class="menu-button" onclick={handleMenuClick} type="button">
<MoreVertical size={16} />
</button>
{#if showMenu}
<div class="menu-dropdown">
<button onclick={() => handleAction('download')}>Herunterladen</button>
<button onclick={() => handleAction('rename')}>Umbenennen</button>
<button onclick={() => handleAction('share')}>Teilen</button>
<button onclick={() => handleAction('favorite')}>
{file.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
</button>
<button onclick={() => handleAction('move')}>Verschieben</button>
<hr />
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
</div>
{/if}
</div>
<style>
.file-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.file-card:hover {
border-color: rgb(var(--color-primary));
box-shadow: var(--shadow-md);
}
.file-icon {
position: relative;
color: rgb(var(--color-primary));
margin-bottom: 0.75rem;
}
.favorite-badge {
position: absolute;
top: -4px;
right: -4px;
color: rgb(var(--color-warning));
}
.file-info {
width: 100%;
overflow: hidden;
}
.file-name {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
display: block;
font-size: 0.75rem;
color: rgb(var(--color-text-secondary));
margin-top: 0.25rem;
}
.menu-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
}
.file-card:hover .menu-button {
opacity: 1;
}
.menu-button:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.menu-dropdown {
position: absolute;
top: 2rem;
right: 0.5rem;
min-width: 150px;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.menu-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
}
.menu-dropdown button:hover {
background: rgb(var(--color-surface));
}
.menu-dropdown button.danger {
color: rgb(var(--color-error));
}
.menu-dropdown hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid rgb(var(--color-border));
}
</style>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import type { StorageFile, StorageFolder } from '$lib/api/client';
import FileCard from './FileCard.svelte';
import FolderCard from './FolderCard.svelte';
interface Props {
files: StorageFile[];
folders: StorageFolder[];
onFileClick?: (file: StorageFile) => void;
onFolderClick?: (folder: StorageFolder) => void;
onFileAction?: (action: string, file: StorageFile) => void;
onFolderAction?: (action: string, folder: StorageFolder) => void;
}
let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props =
$props();
</script>
<div class="file-grid">
{#each folders as folder (folder.id)}
<FolderCard
{folder}
onClick={() => onFolderClick?.(folder)}
onAction={(action) => onFolderAction?.(action, folder)}
/>
{/each}
{#each files as file (file.id)}
<FileCard
{file}
onClick={() => onFileClick?.(file)}
onAction={(action) => onFileAction?.(action, file)}
/>
{/each}
</div>
<style>
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
@media (max-width: 640px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
}
</style>

View file

@ -0,0 +1,97 @@
<script lang="ts">
import type { StorageFile, StorageFolder } from '$lib/api/client';
import FileRow from './FileRow.svelte';
import FolderRow from './FolderRow.svelte';
interface Props {
files: StorageFile[];
folders: StorageFolder[];
onFileClick?: (file: StorageFile) => void;
onFolderClick?: (folder: StorageFolder) => void;
onFileAction?: (action: string, file: StorageFile) => void;
onFolderAction?: (action: string, folder: StorageFolder) => void;
}
let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props =
$props();
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<div class="file-list">
<div class="list-header">
<span class="col-name">Name</span>
<span class="col-size">Größe</span>
<span class="col-date">Geändert</span>
<span class="col-actions"></span>
</div>
<div class="list-body">
{#each folders as folder (folder.id)}
<FolderRow
{folder}
{formatDate}
onClick={() => onFolderClick?.(folder)}
onAction={(action) => onFolderAction?.(action, folder)}
/>
{/each}
{#each files as file (file.id)}
<FileRow
{file}
{formatFileSize}
{formatDate}
onClick={() => onFileClick?.(file)}
onAction={(action) => onFileAction?.(action, file)}
/>
{/each}
</div>
</div>
<style>
.file-list {
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
overflow: hidden;
}
.list-header {
display: grid;
grid-template-columns: 1fr 100px 120px 50px;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgb(var(--color-surface));
border-bottom: 1px solid rgb(var(--color-border));
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: rgb(var(--color-text-secondary));
}
.list-body {
background: rgb(var(--color-surface-elevated));
}
@media (max-width: 640px) {
.list-header {
grid-template-columns: 1fr 50px;
}
.col-size,
.col-date {
display: none;
}
}
</style>

View file

@ -0,0 +1,200 @@
<script lang="ts">
import type { StorageFile } from '$lib/api/client';
import {
File,
FileImage,
FileText,
FileVideo,
FileAudio,
FileArchive,
Heart,
MoreVertical,
} from 'lucide-svelte';
interface Props {
file: StorageFile;
formatFileSize: (bytes: number) => string;
formatDate: (dateStr: string) => string;
onClick?: () => void;
onAction?: (action: string) => void;
}
let { file, formatFileSize, formatDate, onClick, onAction }: Props = $props();
let showMenu = $state(false);
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return FileImage;
if (mimeType.startsWith('video/')) return FileVideo;
if (mimeType.startsWith('audio/')) return FileAudio;
if (mimeType.startsWith('text/')) return FileText;
if (mimeType.includes('zip') || mimeType.includes('archive')) return FileArchive;
return File;
}
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
showMenu = !showMenu;
}
function handleAction(action: string) {
showMenu = false;
onAction?.(action);
}
const Icon = getFileIcon(file.mimeType);
</script>
<div class="file-row" onclick={onClick} role="button" tabindex="0">
<span class="col-name">
<span class="icon">
<Icon size={20} strokeWidth={1.5} />
</span>
<span class="name" title={file.name}>{file.name}</span>
{#if file.isFavorite}
<Heart size={14} fill="currentColor" class="favorite-icon" />
{/if}
</span>
<span class="col-size">{formatFileSize(file.size)}</span>
<span class="col-date">{formatDate(file.updatedAt)}</span>
<span class="col-actions">
<button class="menu-button" onclick={handleMenuClick} type="button">
<MoreVertical size={16} />
</button>
{#if showMenu}
<div class="menu-dropdown">
<button onclick={() => handleAction('download')}>Herunterladen</button>
<button onclick={() => handleAction('rename')}>Umbenennen</button>
<button onclick={() => handleAction('share')}>Teilen</button>
<button onclick={() => handleAction('favorite')}>
{file.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
</button>
<button onclick={() => handleAction('move')}>Verschieben</button>
<hr />
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
</div>
{/if}
</span>
</div>
<style>
.file-row {
display: grid;
grid-template-columns: 1fr 100px 120px 50px;
gap: 1rem;
padding: 0.75rem 1rem;
align-items: center;
border-bottom: 1px solid rgb(var(--color-border));
background: transparent;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
transition: background var(--transition-fast);
width: 100%;
text-align: left;
}
.file-row:hover {
background: rgb(var(--color-surface));
}
.col-name {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.icon {
flex-shrink: 0;
color: rgb(var(--color-primary));
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
}
.col-name :global(.favorite-icon) {
flex-shrink: 0;
color: rgb(var(--color-warning));
}
.col-size,
.col-date {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.col-actions {
position: relative;
}
.menu-button {
padding: 0.25rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
}
.menu-button:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
min-width: 150px;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.menu-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
}
.menu-dropdown button:hover {
background: rgb(var(--color-surface));
}
.menu-dropdown button.danger {
color: rgb(var(--color-error));
}
.menu-dropdown hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid rgb(var(--color-border));
}
@media (max-width: 640px) {
.file-row {
grid-template-columns: 1fr 50px;
}
.col-size,
.col-date {
display: none;
}
}
</style>

View file

@ -0,0 +1,179 @@
<script lang="ts">
import type { StorageFolder } from '$lib/api/client';
import { Folder, Heart, MoreVertical } from 'lucide-svelte';
interface Props {
folder: StorageFolder;
onClick?: () => void;
onAction?: (action: string) => void;
}
let { folder, onClick, onAction }: Props = $props();
let showMenu = $state(false);
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
showMenu = !showMenu;
}
function handleAction(action: string) {
showMenu = false;
onAction?.(action);
}
// Color mapping for folder colors
const colorMap: Record<string, string> = {
blue: '#3b82f6',
green: '#22c55e',
yellow: '#eab308',
red: '#ef4444',
purple: '#a855f7',
pink: '#ec4899',
orange: '#f97316',
teal: '#14b8a6',
};
let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined);
</script>
<div class="folder-card" onclick={onClick} role="button" tabindex="0">
<div class="folder-icon" style:color={folderColor}>
<Folder size={40} strokeWidth={1.5} fill="currentColor" />
{#if folder.isFavorite}
<div class="favorite-badge">
<Heart size={12} fill="currentColor" />
</div>
{/if}
</div>
<div class="folder-info">
<span class="folder-name" title={folder.name}>{folder.name}</span>
</div>
<button class="menu-button" onclick={handleMenuClick} type="button">
<MoreVertical size={16} />
</button>
{#if showMenu}
<div class="menu-dropdown">
<button onclick={() => handleAction('rename')}>Umbenennen</button>
<button onclick={() => handleAction('share')}>Teilen</button>
<button onclick={() => handleAction('favorite')}>
{folder.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
</button>
<button onclick={() => handleAction('move')}>Verschieben</button>
<hr />
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
</div>
{/if}
</div>
<style>
.folder-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.folder-card:hover {
border-color: rgb(var(--color-primary));
box-shadow: var(--shadow-md);
}
.folder-icon {
position: relative;
color: rgb(var(--color-primary));
margin-bottom: 0.75rem;
}
.favorite-badge {
position: absolute;
top: -4px;
right: -4px;
color: rgb(var(--color-warning));
}
.folder-info {
width: 100%;
overflow: hidden;
}
.folder-name {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
}
.folder-card:hover .menu-button {
opacity: 1;
}
.menu-button:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.menu-dropdown {
position: absolute;
top: 2rem;
right: 0.5rem;
min-width: 150px;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.menu-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
}
.menu-dropdown button:hover {
background: rgb(var(--color-surface));
}
.menu-dropdown button.danger {
color: rgb(var(--color-error));
}
.menu-dropdown hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid rgb(var(--color-border));
}
</style>

View file

@ -0,0 +1,193 @@
<script lang="ts">
import type { StorageFolder } from '$lib/api/client';
import { Folder, Heart, MoreVertical } from 'lucide-svelte';
interface Props {
folder: StorageFolder;
formatDate: (dateStr: string) => string;
onClick?: () => void;
onAction?: (action: string) => void;
}
let { folder, formatDate, onClick, onAction }: Props = $props();
let showMenu = $state(false);
// Color mapping for folder colors
const colorMap: Record<string, string> = {
blue: '#3b82f6',
green: '#22c55e',
yellow: '#eab308',
red: '#ef4444',
purple: '#a855f7',
pink: '#ec4899',
orange: '#f97316',
teal: '#14b8a6',
};
let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined);
function handleMenuClick(e: MouseEvent) {
e.stopPropagation();
showMenu = !showMenu;
}
function handleAction(action: string) {
showMenu = false;
onAction?.(action);
}
</script>
<div class="folder-row" onclick={onClick} role="button" tabindex="0">
<span class="col-name">
<span class="icon" style:color={folderColor}>
<Folder size={20} strokeWidth={1.5} fill="currentColor" />
</span>
<span class="name" title={folder.name}>{folder.name}</span>
{#if folder.isFavorite}
<Heart size={14} fill="currentColor" class="favorite-icon" />
{/if}
</span>
<span class="col-size"></span>
<span class="col-date">{formatDate(folder.updatedAt)}</span>
<span class="col-actions">
<button class="menu-button" onclick={handleMenuClick} type="button">
<MoreVertical size={16} />
</button>
{#if showMenu}
<div class="menu-dropdown">
<button onclick={() => handleAction('rename')}>Umbenennen</button>
<button onclick={() => handleAction('share')}>Teilen</button>
<button onclick={() => handleAction('favorite')}>
{folder.isFavorite ? 'Favorit entfernen' : 'Als Favorit'}
</button>
<button onclick={() => handleAction('move')}>Verschieben</button>
<hr />
<button class="danger" onclick={() => handleAction('delete')}>Löschen</button>
</div>
{/if}
</span>
</div>
<style>
.folder-row {
display: grid;
grid-template-columns: 1fr 100px 120px 50px;
gap: 1rem;
padding: 0.75rem 1rem;
align-items: center;
border-bottom: 1px solid rgb(var(--color-border));
background: transparent;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
transition: background var(--transition-fast);
width: 100%;
text-align: left;
}
.folder-row:hover {
background: rgb(var(--color-surface));
}
.col-name {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.icon {
flex-shrink: 0;
color: rgb(var(--color-primary));
}
.name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
font-weight: 500;
}
.col-name :global(.favorite-icon) {
flex-shrink: 0;
color: rgb(var(--color-warning));
}
.col-size,
.col-date {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.col-actions {
position: relative;
}
.menu-button {
padding: 0.25rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
}
.menu-button:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.menu-dropdown {
position: absolute;
top: 100%;
right: 0;
min-width: 150px;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.menu-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: none;
border: none;
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
}
.menu-dropdown button:hover {
background: rgb(var(--color-surface));
}
.menu-dropdown button.danger {
color: rgb(var(--color-error));
}
.menu-dropdown hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid rgb(var(--color-border));
}
@media (max-width: 640px) {
.folder-row {
grid-template-columns: 1fr 50px;
}
.col-size,
.col-date {
display: none;
}
}
</style>

View file

@ -0,0 +1,283 @@
<script lang="ts">
import { X } from 'lucide-svelte';
interface Props {
open: boolean;
onClose: () => void;
onCreate: (name: string, color?: string) => void;
}
let { open, onClose, onCreate }: Props = $props();
let folderName = $state('');
let selectedColor = $state<string | undefined>(undefined);
let loading = $state(false);
const colors = [
{ id: 'blue', value: '#3b82f6', label: 'Blau' },
{ id: 'green', value: '#22c55e', label: 'Grün' },
{ id: 'yellow', value: '#eab308', label: 'Gelb' },
{ id: 'red', value: '#ef4444', label: 'Rot' },
{ id: 'purple', value: '#a855f7', label: 'Lila' },
{ id: 'pink', value: '#ec4899', label: 'Pink' },
{ id: 'orange', value: '#f97316', label: 'Orange' },
{ id: 'teal', value: '#14b8a6', label: 'Türkis' },
];
async function handleSubmit() {
if (!folderName.trim()) return;
loading = true;
try {
await onCreate(folderName.trim(), selectedColor);
folderName = '';
selectedColor = undefined;
onClose();
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
{#if open}
<div
class="modal-overlay"
onclick={onClose}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h2 id="modal-title">Neuer Ordner</h2>
<button class="close-button" onclick={onClose} aria-label="Schließen">
<X size={20} />
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="form-group">
<label for="folder-name">Ordnername</label>
<input
type="text"
id="folder-name"
bind:value={folderName}
placeholder="Neuer Ordner"
autofocus
/>
</div>
<div class="form-group">
<label>Ordnerfarbe (optional)</label>
<div class="color-picker">
<button
type="button"
class="color-option default"
class:selected={!selectedColor}
onclick={() => (selectedColor = undefined)}
aria-label="Standard"
>
<span class="checkmark"></span>
</button>
{#each colors as color (color.id)}
<button
type="button"
class="color-option"
class:selected={selectedColor === color.id}
style="background-color: {color.value}"
onclick={() => (selectedColor = color.id)}
aria-label={color.label}
>
{#if selectedColor === color.id}
<span class="checkmark white"></span>
{/if}
</button>
{/each}
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={onClose}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={!folderName.trim() || loading}>
{loading ? 'Erstellen...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background: rgb(var(--color-surface-elevated));
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
width: 100%;
max-width: 400px;
margin: 1rem;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid rgb(var(--color-border));
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.close-button {
padding: 0.25rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
}
.close-button:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
}
.form-group input {
width: 100%;
padding: 0.75rem;
background: rgb(var(--color-surface));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
}
.form-group input:focus {
outline: none;
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 3px rgba(var(--color-primary), 0.1);
}
.color-picker {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-option {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.color-option.default {
background: rgb(var(--color-surface));
border-color: rgb(var(--color-border));
}
.color-option.selected {
border-color: rgb(var(--color-text-primary));
transform: scale(1.1);
}
.checkmark {
font-size: 0.75rem;
color: rgb(var(--color-text-primary));
}
.checkmark.white {
color: white;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn-secondary,
.btn-primary {
padding: 0.625rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-secondary {
background: transparent;
border: 1px solid rgb(var(--color-border));
color: rgb(var(--color-text-primary));
}
.btn-secondary:hover {
background: rgb(var(--color-surface));
}
.btn-primary {
background: rgb(var(--color-primary));
border: none;
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,157 @@
<script lang="ts">
import { Upload, X } from 'lucide-svelte';
interface Props {
onUpload: (files: FileList) => void;
uploading?: boolean;
progress?: number;
}
let { onUpload, uploading = false, progress = 0 }: Props = $props();
let isDragging = $state(false);
let fileInput: HTMLInputElement;
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDragging = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
onUpload(e.dataTransfer.files);
}
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
onUpload(target.files);
target.value = '';
}
}
function openFileDialog() {
fileInput?.click();
}
</script>
<div
class="upload-zone"
class:dragging={isDragging}
class:uploading
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="button"
tabindex="0"
onclick={openFileDialog}
onkeydown={(e) => e.key === 'Enter' && openFileDialog()}
>
<input
type="file"
multiple
bind:this={fileInput}
onchange={handleFileSelect}
class="file-input"
aria-label="Dateien auswählen"
/>
{#if uploading}
<div class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
<span class="progress-text">Hochladen... {progress}%</span>
</div>
{:else}
<div class="upload-content">
<Upload size={32} />
<span class="upload-text">
Dateien hierher ziehen oder <strong>klicken</strong> zum Auswählen
</span>
</div>
{/if}
</div>
<style>
.upload-zone {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
border: 2px dashed rgb(var(--color-border));
border-radius: var(--radius-lg);
background: rgb(var(--color-surface));
cursor: pointer;
transition: all var(--transition-fast);
}
.upload-zone:hover,
.upload-zone.dragging {
border-color: rgb(var(--color-primary));
background: rgba(var(--color-primary), 0.05);
}
.upload-zone.uploading {
cursor: default;
}
.file-input {
display: none;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: rgb(var(--color-text-secondary));
}
.upload-text {
font-size: 0.875rem;
text-align: center;
}
.upload-text strong {
color: rgb(var(--color-primary));
}
.upload-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
max-width: 300px;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgb(var(--color-surface-elevated));
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: rgb(var(--color-primary));
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
</style>