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,9 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-feedback-ui/src";

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storage - Cloud Drive</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,274 @@
/**
* API Client for Storage Backend
*/
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE_URL = 'http://localhost:3016/api/v1';
export interface ApiResponse<T> {
data?: T;
error?: string;
}
async function getHeaders(): Promise<HeadersInit> {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
try {
const headers = await getHeaders();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...headers,
...(options.headers || {}),
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.message || `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
// File Types
export interface StorageFile {
id: string;
userId: string;
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId: string | null;
currentVersion: number;
isFavorite: boolean;
isDeleted: boolean;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface StorageFolder {
id: string;
userId: string;
name: string;
description: string | null;
color: string | null;
parentFolderId: string | null;
path: string;
depth: number;
isFavorite: boolean;
isDeleted: boolean;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface Share {
id: string;
userId: string;
fileId: string | null;
folderId: string | null;
shareType: 'file' | 'folder';
shareToken: string;
accessLevel: 'view' | 'edit' | 'download';
password: string | null;
maxDownloads: number | null;
downloadCount: number;
expiresAt: string | null;
isActive: boolean;
createdAt: string;
}
export interface Tag {
id: string;
userId: string;
name: string;
color: string | null;
createdAt: string;
}
// Files API
export const filesApi = {
list: (folderId?: string) =>
request<StorageFile[]>(`/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) => request<StorageFile>(`/files/${id}`),
upload: async (file: File, folderId?: string): Promise<ApiResponse<StorageFile>> => {
const token = await authStore.getAccessToken();
const formData = new FormData();
formData.append('file', file);
if (folderId) {
formData.append('parentFolderId', folderId);
}
try {
const response = await fetch(`${API_BASE_URL}/files/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.message || `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
},
download: async (id: string): Promise<Blob | null> => {
const token = await authStore.getAccessToken();
try {
const response = await fetch(`${API_BASE_URL}/files/${id}/download`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) return null;
return await response.blob();
} catch {
return null;
}
},
rename: (id: string, name: string) =>
request<StorageFile>(`/files/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
}),
move: (id: string, parentFolderId: string | null) =>
request<StorageFile>(`/files/${id}/move`, {
method: 'PATCH',
body: JSON.stringify({ parentFolderId }),
}),
delete: (id: string) => request<{ success: boolean }>(`/files/${id}`, { method: 'DELETE' }),
toggleFavorite: (id: string) => request<StorageFile>(`/files/${id}/favorite`, { method: 'POST' }),
};
// Folders API
export const foldersApi = {
list: (parentId?: string) =>
request<StorageFolder[]>(`/folders${parentId ? `?parentId=${parentId}` : ''}`),
get: (id: string) =>
request<{ folder: StorageFolder; files: StorageFile[]; subfolders: StorageFolder[] }>(
`/folders/${id}`
),
create: (name: string, parentFolderId?: string, color?: string) =>
request<StorageFolder>('/folders', {
method: 'POST',
body: JSON.stringify({ name, parentFolderId, color }),
}),
rename: (id: string, name: string) =>
request<StorageFolder>(`/folders/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
}),
move: (id: string, parentFolderId: string | null) =>
request<StorageFolder>(`/folders/${id}/move`, {
method: 'PATCH',
body: JSON.stringify({ parentFolderId }),
}),
delete: (id: string) => request<{ success: boolean }>(`/folders/${id}`, { method: 'DELETE' }),
toggleFavorite: (id: string) =>
request<StorageFolder>(`/folders/${id}/favorite`, { method: 'POST' }),
};
// Shares API
export const sharesApi = {
list: () => request<Share[]>('/shares'),
get: (token: string) =>
request<{ share: Share; file?: StorageFile; folder?: StorageFolder }>(`/shares/${token}`),
create: (data: {
fileId?: string;
folderId?: string;
accessLevel?: 'view' | 'edit' | 'download';
password?: string;
maxDownloads?: number;
expiresAt?: string;
}) =>
request<Share>('/shares', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => request<{ success: boolean }>(`/shares/${id}`, { method: 'DELETE' }),
};
// Tags API
export const tagsApi = {
list: () => request<Tag[]>('/tags'),
create: (name: string, color?: string) =>
request<Tag>('/tags', {
method: 'POST',
body: JSON.stringify({ name, color }),
}),
update: (id: string, data: { name?: string; color?: string }) =>
request<Tag>(`/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) => request<{ success: boolean }>(`/tags/${id}`, { method: 'DELETE' }),
};
// Trash API
export const trashApi = {
list: () => request<{ files: StorageFile[]; folders: StorageFolder[] }>('/trash'),
restore: (id: string, type: 'file' | 'folder') =>
request<StorageFile | StorageFolder>(`/trash/${id}/restore?type=${type}`, {
method: 'POST',
}),
permanentDelete: (id: string, type: 'file' | 'folder') =>
request<{ success: boolean }>(`/trash/${id}?type=${type}`, { method: 'DELETE' }),
empty: () => request<{ success: boolean }>('/trash', { method: 'DELETE' }),
};
// Search API
export const searchApi = {
search: (query: string) =>
request<{ files: StorageFile[]; folders: StorageFolder[] }>(
`/search?q=${encodeURIComponent(query)}`
),
favorites: () => request<{ files: StorageFile[]; folders: StorageFolder[] }>('/favorites'),
};

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>

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('storage_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('storage_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,77 @@
{
"app": {
"name": "Storage",
"description": "Sichere Cloud-Speicherung für deine Dateien"
},
"nav": {
"files": "Dateien",
"shared": "Geteilt",
"favorites": "Favoriten",
"trash": "Papierkorb",
"search": "Suche",
"settings": "Einstellungen",
"profile": "Profil",
"feedback": "Feedback"
},
"files": {
"title": "Meine Dateien",
"upload": "Hochladen",
"newFolder": "Neuer Ordner",
"empty": "Keine Dateien vorhanden",
"dropHere": "Dateien hier ablegen",
"viewGrid": "Rasteransicht",
"viewList": "Listenansicht"
},
"folder": {
"create": "Ordner erstellen",
"name": "Ordnername",
"color": "Ordnerfarbe"
},
"actions": {
"download": "Herunterladen",
"rename": "Umbenennen",
"move": "Verschieben",
"share": "Teilen",
"favorite": "Als Favorit markieren",
"unfavorite": "Favorit entfernen",
"delete": "Löschen",
"restore": "Wiederherstellen",
"permanentDelete": "Endgültig löschen"
},
"trash": {
"title": "Papierkorb",
"empty": "Papierkorb ist leer",
"emptyTrash": "Papierkorb leeren",
"restoreAll": "Alle wiederherstellen"
},
"share": {
"title": "Teilen",
"createLink": "Link erstellen",
"copyLink": "Link kopieren",
"linkCopied": "Link kopiert!",
"accessLevel": "Zugriffsebene",
"view": "Ansehen",
"download": "Herunterladen",
"edit": "Bearbeiten",
"password": "Passwortschutz",
"expiration": "Ablaufdatum",
"maxDownloads": "Max. Downloads"
},
"search": {
"title": "Suche",
"placeholder": "Dateien und Ordner durchsuchen...",
"noResults": "Keine Ergebnisse gefunden"
},
"favorites": {
"title": "Favoriten",
"empty": "Keine Favoriten vorhanden"
},
"common": {
"loading": "Laden...",
"save": "Speichern",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"error": "Fehler",
"success": "Erfolgreich"
}
}

View file

@ -0,0 +1,77 @@
{
"app": {
"name": "Storage",
"description": "Secure cloud storage for your files"
},
"nav": {
"files": "Files",
"shared": "Shared",
"favorites": "Favorites",
"trash": "Trash",
"search": "Search",
"settings": "Settings",
"profile": "Profile",
"feedback": "Feedback"
},
"files": {
"title": "My Files",
"upload": "Upload",
"newFolder": "New Folder",
"empty": "No files yet",
"dropHere": "Drop files here",
"viewGrid": "Grid view",
"viewList": "List view"
},
"folder": {
"create": "Create folder",
"name": "Folder name",
"color": "Folder color"
},
"actions": {
"download": "Download",
"rename": "Rename",
"move": "Move",
"share": "Share",
"favorite": "Add to favorites",
"unfavorite": "Remove from favorites",
"delete": "Delete",
"restore": "Restore",
"permanentDelete": "Delete permanently"
},
"trash": {
"title": "Trash",
"empty": "Trash is empty",
"emptyTrash": "Empty trash",
"restoreAll": "Restore all"
},
"share": {
"title": "Share",
"createLink": "Create link",
"copyLink": "Copy link",
"linkCopied": "Link copied!",
"accessLevel": "Access level",
"view": "View",
"download": "Download",
"edit": "Edit",
"password": "Password protection",
"expiration": "Expiration date",
"maxDownloads": "Max downloads"
},
"search": {
"title": "Search",
"placeholder": "Search files and folders...",
"noResults": "No results found"
},
"favorites": {
"title": "Favorites",
"empty": "No favorites yet"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"error": "Error",
"success": "Success"
}
}

View file

@ -0,0 +1,159 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
const MANA_AUTH_URL = 'http://localhost:3001';
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
user = null;
}
},
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,173 @@
/**
* Files Store - Manages files and folders state
*/
import { filesApi, foldersApi } from '$lib/api/client';
import type { StorageFile, StorageFolder } from '$lib/api/client';
let files = $state<StorageFile[]>([]);
let folders = $state<StorageFolder[]>([]);
let currentFolder = $state<StorageFolder | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let viewMode = $state<'grid' | 'list'>('grid');
export const filesStore = {
get files() {
return files;
},
get folders() {
return folders;
},
get currentFolder() {
return currentFolder;
},
get loading() {
return loading;
},
get error() {
return error;
},
get viewMode() {
return viewMode;
},
setViewMode(mode: 'grid' | 'list') {
viewMode = mode;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('storage-view-mode', mode);
}
},
initViewMode() {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('storage-view-mode');
if (saved === 'grid' || saved === 'list') {
viewMode = saved;
}
}
},
async loadFolder(folderId?: string) {
loading = true;
error = null;
try {
if (folderId) {
const result = await foldersApi.get(folderId);
if (result.error) {
error = result.error;
return;
}
if (result.data) {
currentFolder = result.data.folder;
files = result.data.files;
folders = result.data.subfolders;
}
} else {
// Load root
currentFolder = null;
const [filesResult, foldersResult] = await Promise.all([
filesApi.list(),
foldersApi.list(),
]);
if (filesResult.error) {
error = filesResult.error;
return;
}
if (foldersResult.error) {
error = foldersResult.error;
return;
}
files = filesResult.data || [];
folders = foldersResult.data || [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading = false;
}
},
async uploadFile(file: File) {
const result = await filesApi.upload(file, currentFolder?.id);
if (result.data) {
files = [...files, result.data];
}
return result;
},
async createFolder(name: string, color?: string) {
const result = await foldersApi.create(name, currentFolder?.id, color);
if (result.data) {
folders = [...folders, result.data];
}
return result;
},
async deleteFile(id: string) {
const result = await filesApi.delete(id);
if (!result.error) {
files = files.filter((f) => f.id !== id);
}
return result;
},
async deleteFolder(id: string) {
const result = await foldersApi.delete(id);
if (!result.error) {
folders = folders.filter((f) => f.id !== id);
}
return result;
},
async toggleFileFavorite(id: string) {
const result = await filesApi.toggleFavorite(id);
if (result.data) {
files = files.map((f) => (f.id === id ? result.data! : f));
}
return result;
},
async toggleFolderFavorite(id: string) {
const result = await foldersApi.toggleFavorite(id);
if (result.data) {
folders = folders.map((f) => (f.id === id ? result.data! : f));
}
return result;
},
async renameFile(id: string, name: string) {
const result = await filesApi.rename(id, name);
if (result.data) {
files = files.map((f) => (f.id === id ? result.data! : f));
}
return result;
},
async renameFolder(id: string, name: string) {
const result = await foldersApi.rename(id, name);
if (result.data) {
folders = folders.map((f) => (f.id === id ? result.data! : f));
}
return result;
},
async downloadFile(id: string, filename: string) {
const blob = await filesApi.download(id);
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
}
return false;
},
};

View file

@ -0,0 +1,8 @@
/**
* Navigation Store - Manages sidebar and navigation state
*/
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,95 @@
/**
* Theme Store - Manages theme state
*/
import { browser } from '$app/environment';
import {
THEME_DEFINITIONS,
THEME_VARIANTS,
type ThemeMode,
type ThemeVariant,
DEFAULT_VARIANT,
} from '@manacore/shared-theme';
const STORAGE_KEY_MODE = 'storage-theme-mode';
const STORAGE_KEY_VARIANT = 'storage-theme-variant';
function createThemeStore() {
let mode = $state<ThemeMode>('system');
let variant = $state<ThemeVariant>(DEFAULT_VARIANT);
let systemPrefersDark = $state(false);
function getSystemPreference(): boolean {
if (!browser) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyTheme() {
if (!browser) return;
const isDarkMode = mode === 'dark' || (mode === 'system' && systemPrefersDark);
const themeClass = isDarkMode
? THEME_DEFINITIONS[variant].darkClass
: THEME_DEFINITIONS[variant].lightClass;
document.documentElement.className = themeClass;
}
return {
get mode() {
return mode;
},
get variant() {
return variant;
},
get isDark() {
return mode === 'dark' || (mode === 'system' && systemPrefersDark);
},
get variants() {
return THEME_VARIANTS;
},
initialize() {
if (!browser) return;
const savedMode = localStorage.getItem(STORAGE_KEY_MODE) as ThemeMode | null;
const savedVariant = localStorage.getItem(STORAGE_KEY_VARIANT) as ThemeVariant | null;
if (savedMode) mode = savedMode;
if (savedVariant && savedVariant in THEME_DEFINITIONS) variant = savedVariant;
systemPrefersDark = getSystemPreference();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
systemPrefersDark = e.matches;
applyTheme();
});
applyTheme();
},
setMode(newMode: ThemeMode) {
mode = newMode;
if (browser) {
localStorage.setItem(STORAGE_KEY_MODE, newMode);
}
applyTheme();
},
setVariant(newVariant: ThemeVariant) {
variant = newVariant;
if (browser) {
localStorage.setItem(STORAGE_KEY_VARIANT, newVariant);
}
applyTheme();
},
toggleMode() {
const newMode = mode === 'dark' ? 'light' : 'dark';
this.setMode(newMode);
},
};
}
export const theme = createThemeStore();

View file

@ -0,0 +1,63 @@
/**
* Toast Store - Manages toast notifications
*/
import { writable } from 'svelte/store';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration?: number;
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
function add(toast: Omit<Toast, 'id'>) {
const id = crypto.randomUUID();
const duration = toast.duration ?? 5000;
update((toasts) => [...toasts, { ...toast, id }]);
if (duration > 0) {
setTimeout(() => {
remove(id);
}, duration);
}
return id;
}
function remove(id: string) {
update((toasts) => toasts.filter((t) => t.id !== id));
}
function success(message: string, duration?: number) {
return add({ type: 'success', message, duration });
}
function error(message: string, duration?: number) {
return add({ type: 'error', message, duration });
}
function warning(message: string, duration?: number) {
return add({ type: 'warning', message, duration });
}
function info(message: string, duration?: number) {
return add({ type: 'info', message, duration });
}
return {
subscribe,
add,
remove,
success,
error,
warning,
info,
};
}
export const toast = createToastStore();

View file

@ -0,0 +1,19 @@
/**
* User Settings Store for Storage
*
* This store syncs settings with mana-core-auth and provides:
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*/
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
export const userSettings = createUserSettingsStore({
appId: 'storage',
authUrl: MANA_AUTH_URL,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import '../app.css';
// App switcher items
const appItems = getPillAppItems('storage');
let { children } = $props();
let loading = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Storage
const navItems: PillNavItem[] = [
{ href: '/files', label: 'Dateien', icon: 'folder' },
{ href: '/shared', label: 'Geteilt', icon: 'share' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/trash', label: 'Papierkorb', icon: 'trash' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts
const navRoutes = navItems.map((item) => item.href);
// Check if current path is auth page
let isAuthPage = $derived(
$page.url.pathname === '/login' ||
$page.url.pathname === '/register' ||
$page.url.pathname === '/forgot-password'
);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
// Cmd/Ctrl+K to open search
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
goto('/search');
return;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('storage-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('storage-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.initialize();
// Load user settings
await userSettings.load();
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('storage-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('storage-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
loading = false;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<ToastContainer />
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else if isAuthPage}
<!-- Auth pages without navigation -->
{@render children()}
{:else}
<!-- Navigation Layout -->
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Storage"
homeRoute="/files"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
transition: all 300ms ease;
}
.main-content.floating-mode {
padding-top: 100px;
}
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// Redirect to /files on root
onMount(() => {
goto('/files');
});
</script>
<div class="flex min-h-screen items-center justify-center">
<p class="text-muted-foreground">Weiterleitung...</p>
</div>

View file

@ -0,0 +1,237 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { Heart, Grid, List } from 'lucide-svelte';
import { searchApi } from '$lib/api/client';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import { filesStore } from '$lib/stores/files.svelte';
import { toast } from '$lib/stores/toast';
import FileGrid from '$lib/components/files/FileGrid.svelte';
import FileList from '$lib/components/files/FileList.svelte';
let files = $state<StorageFile[]>([]);
let folders = $state<StorageFolder[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
filesStore.initViewMode();
await loadFavorites();
});
async function loadFavorites() {
loading = true;
error = null;
const result = await searchApi.favorites();
if (result.error) {
error = result.error;
} else if (result.data) {
files = result.data.files;
folders = result.data.folders;
}
loading = false;
}
function handleFolderClick(folder: StorageFolder) {
goto(`/files/${folder.id}`);
}
function handleFileClick(file: StorageFile) {
console.log('File clicked:', file);
}
async function handleFileAction(action: string, file: StorageFile) {
if (action === 'favorite') {
const result = await filesStore.toggleFileFavorite(file.id);
if (!result.error) {
files = files.filter((f) => f.id !== file.id);
toast.success('Favorit entfernt');
}
}
}
async function handleFolderAction(action: string, folder: StorageFolder) {
if (action === 'favorite') {
const result = await filesStore.toggleFolderFavorite(folder.id);
if (!result.error) {
folders = folders.filter((f) => f.id !== folder.id);
toast.success('Favorit entfernt');
}
}
}
</script>
<svelte:head>
<title>Favoriten - Storage</title>
</svelte:head>
<div class="favorites-page">
<div class="page-header">
<h1>
<Heart size={24} />
Favoriten
</h1>
<div class="view-toggle">
<button
class="view-btn"
class:active={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<Grid size={18} />
</button>
<button
class="view-btn"
class:active={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
</div>
{#if loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Laden...</p>
</div>
{:else if error}
<div class="error-state">
<p>Fehler: {error}</p>
<button onclick={loadFavorites}>Erneut versuchen</button>
</div>
{:else if files.length === 0 && folders.length === 0}
<div class="empty-state">
<Heart size={48} />
<h2>Keine Favoriten</h2>
<p>Markiere Dateien und Ordner als Favoriten, um sie hier schnell zu finden.</p>
</div>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
{files}
{folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
/>
{:else}
<FileList
{files}
{folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
/>
{/if}
</div>
<style>
.favorites-page {
min-height: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.view-toggle {
display: flex;
background: rgb(var(--color-surface));
border-radius: var(--radius-md);
padding: 0.25rem;
}
.view-btn {
padding: 0.5rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn:hover {
color: rgb(var(--color-text-primary));
}
.view-btn.active {
background: rgb(var(--color-surface-elevated));
color: rgb(var(--color-primary));
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p,
.error-state p {
margin-top: 1rem;
color: rgb(var(--color-text-secondary));
}
.error-state button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
color: white;
cursor: pointer;
}
.empty-state {
color: rgb(var(--color-text-secondary));
}
.empty-state h2 {
margin: 1rem 0 0.5rem;
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
}
.empty-state p {
margin: 0;
}
</style>

View file

@ -0,0 +1,223 @@
<script lang="ts">
import { MessageSquare, Send } from 'lucide-svelte';
import { toast } from '$lib/stores/toast';
let type = $state<'bug' | 'feature' | 'other'>('feature');
let message = $state('');
let sending = $state(false);
async function handleSubmit() {
if (!message.trim()) return;
sending = true;
// Simulate sending feedback
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('Feedback gesendet! Vielen Dank.');
message = '';
type = 'feature';
sending = false;
}
</script>
<svelte:head>
<title>Feedback - Storage</title>
</svelte:head>
<div class="feedback-page">
<div class="page-header">
<h1>
<MessageSquare size={24} />
Feedback
</h1>
</div>
<div class="feedback-card">
<p class="intro">
Wir freuen uns über dein Feedback! Teile uns mit, was wir verbessern können oder welche
Funktionen du dir wünschst.
</p>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="form-group">
<label>Art des Feedbacks</label>
<div class="type-selector">
<button
type="button"
class="type-btn"
class:active={type === 'bug'}
onclick={() => (type = 'bug')}
>
Bug melden
</button>
<button
type="button"
class="type-btn"
class:active={type === 'feature'}
onclick={() => (type = 'feature')}
>
Feature-Wunsch
</button>
<button
type="button"
class="type-btn"
class:active={type === 'other'}
onclick={() => (type = 'other')}
>
Sonstiges
</button>
</div>
</div>
<div class="form-group">
<label for="message">Deine Nachricht</label>
<textarea
id="message"
bind:value={message}
placeholder="Beschreibe dein Feedback hier..."
rows="6"
></textarea>
</div>
<button type="submit" class="submit-btn" disabled={!message.trim() || sending}>
<Send size={18} />
{sending ? 'Wird gesendet...' : 'Feedback senden'}
</button>
</form>
</div>
</div>
<style>
.feedback-page {
min-height: 100%;
max-width: 600px;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.feedback-card {
padding: 2rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-xl);
}
.intro {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
line-height: 1.6;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
}
.type-selector {
display: flex;
gap: 0.5rem;
}
.type-btn {
flex: 1;
padding: 0.625rem;
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-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.type-btn:hover {
border-color: rgb(var(--color-primary));
color: rgb(var(--color-text-primary));
}
.type-btn.active {
background: rgba(var(--color-primary), 0.1);
border-color: rgb(var(--color-primary));
color: rgb(var(--color-primary));
}
textarea {
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));
resize: vertical;
font-family: inherit;
}
textarea:focus {
outline: none;
border-color: rgb(var(--color-primary));
box-shadow: 0 0 0 3px rgba(var(--color-primary), 0.1);
}
textarea::placeholder {
color: rgb(var(--color-text-tertiary));
}
.submit-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
color: white;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.submit-btn:hover:not(:disabled) {
opacity: 0.9;
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 480px) {
.type-selector {
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,417 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { Grid, List, Plus, FolderPlus, Upload } from 'lucide-svelte';
import { filesStore } from '$lib/stores/files.svelte';
import { toast } from '$lib/stores/toast';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import FileGrid from '$lib/components/files/FileGrid.svelte';
import FileList from '$lib/components/files/FileList.svelte';
import Breadcrumb from '$lib/components/files/Breadcrumb.svelte';
import UploadZone from '$lib/components/files/UploadZone.svelte';
import NewFolderModal from '$lib/components/files/NewFolderModal.svelte';
let showUploadZone = $state(false);
let showNewFolderModal = $state(false);
let uploading = $state(false);
let uploadProgress = $state(0);
// Breadcrumb items from current folder path
let breadcrumbItems = $derived(
filesStore.currentFolder
? [{ id: filesStore.currentFolder.id, name: filesStore.currentFolder.name }]
: []
);
onMount(() => {
filesStore.initViewMode();
filesStore.loadFolder();
});
function handleFolderClick(folder: StorageFolder) {
goto(`/files/${folder.id}`);
}
function handleFileClick(file: StorageFile) {
// TODO: Open file preview
console.log('File clicked:', file);
}
async function handleFileAction(action: string, file: StorageFile) {
switch (action) {
case 'download':
await filesStore.downloadFile(file.id, file.name);
toast.success('Download gestartet');
break;
case 'rename':
const newName = prompt('Neuer Name:', file.name);
if (newName && newName !== file.name) {
const result = await filesStore.renameFile(file.id, newName);
if (result.error) {
toast.error(result.error);
} else {
toast.success('Datei umbenannt');
}
}
break;
case 'favorite':
const favResult = await filesStore.toggleFileFavorite(file.id);
if (!favResult.error) {
toast.success(file.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
}
break;
case 'delete':
if (confirm('Datei in den Papierkorb verschieben?')) {
const delResult = await filesStore.deleteFile(file.id);
if (delResult.error) {
toast.error(delResult.error);
} else {
toast.success('In den Papierkorb verschoben');
}
}
break;
case 'share':
// TODO: Open share modal
toast.info('Teilen-Funktion kommt bald');
break;
case 'move':
// TODO: Open move modal
toast.info('Verschieben-Funktion kommt bald');
break;
}
}
async function handleFolderAction(action: string, folder: StorageFolder) {
switch (action) {
case 'rename':
const newName = prompt('Neuer Name:', folder.name);
if (newName && newName !== folder.name) {
const result = await filesStore.renameFolder(folder.id, newName);
if (result.error) {
toast.error(result.error);
} else {
toast.success('Ordner umbenannt');
}
}
break;
case 'favorite':
const favResult = await filesStore.toggleFolderFavorite(folder.id);
if (!favResult.error) {
toast.success(folder.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
}
break;
case 'delete':
if (confirm('Ordner und Inhalt in den Papierkorb verschieben?')) {
const delResult = await filesStore.deleteFolder(folder.id);
if (delResult.error) {
toast.error(delResult.error);
} else {
toast.success('In den Papierkorb verschoben');
}
}
break;
case 'share':
toast.info('Teilen-Funktion kommt bald');
break;
case 'move':
toast.info('Verschieben-Funktion kommt bald');
break;
}
}
async function handleUpload(files: FileList) {
uploading = true;
uploadProgress = 0;
const totalFiles = files.length;
let completed = 0;
for (const file of files) {
const result = await filesStore.uploadFile(file);
if (result.error) {
toast.error(`Fehler beim Hochladen von ${file.name}: ${result.error}`);
}
completed++;
uploadProgress = Math.round((completed / totalFiles) * 100);
}
uploading = false;
uploadProgress = 0;
showUploadZone = false;
toast.success(`${totalFiles} Datei(en) hochgeladen`);
}
async function handleCreateFolder(name: string, color?: string) {
const result = await filesStore.createFolder(name, color);
if (result.error) {
toast.error(result.error);
} else {
toast.success('Ordner erstellt');
}
}
function handleBreadcrumbNavigate(id: string | null) {
if (id) {
goto(`/files/${id}`);
} else {
goto('/files');
}
}
</script>
<svelte:head>
<title>Meine Dateien - Storage</title>
</svelte:head>
<div class="files-page">
<div class="page-header">
<div class="header-left">
<h1>Meine Dateien</h1>
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
</div>
<div class="header-actions">
<div class="view-toggle">
<button
class="view-btn"
class:active={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<Grid size={18} />
</button>
<button
class="view-btn"
class:active={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
<FolderPlus size={18} />
<span>Neuer Ordner</span>
</button>
<button class="action-btn primary" onclick={() => (showUploadZone = !showUploadZone)}>
<Upload size={18} />
<span>Hochladen</span>
</button>
</div>
</div>
{#if showUploadZone}
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
{/if}
{#if filesStore.loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Laden...</p>
</div>
{:else if filesStore.error}
<div class="error-state">
<p>Fehler: {filesStore.error}</p>
<button onclick={() => filesStore.loadFolder()}>Erneut versuchen</button>
</div>
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
<div class="empty-state">
<Upload size={48} />
<h2>Noch keine Dateien</h2>
<p>Lade deine ersten Dateien hoch oder erstelle einen Ordner.</p>
<div class="empty-actions">
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
<FolderPlus size={18} />
<span>Neuer Ordner</span>
</button>
<button class="action-btn primary" onclick={() => (showUploadZone = true)}>
<Upload size={18} />
<span>Hochladen</span>
</button>
</div>
</div>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
files={filesStore.files}
folders={filesStore.folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
/>
{:else}
<FileList
files={filesStore.files}
folders={filesStore.folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
/>
{/if}
</div>
<NewFolderModal
open={showNewFolderModal}
onClose={() => (showNewFolderModal = false)}
onCreate={handleCreateFolder}
/>
<style>
.files-page {
min-height: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header-left h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.view-toggle {
display: flex;
background: rgb(var(--color-surface));
border-radius: var(--radius-md);
padding: 0.25rem;
}
.view-btn {
padding: 0.5rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn:hover {
color: rgb(var(--color-text-primary));
}
.view-btn.active {
background: rgb(var(--color-surface-elevated));
color: rgb(var(--color-primary));
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
transition: all var(--transition-fast);
}
.action-btn:hover {
background: rgb(var(--color-surface));
}
.action-btn.primary {
background: rgb(var(--color-primary));
border-color: rgb(var(--color-primary));
color: white;
}
.action-btn.primary:hover {
opacity: 0.9;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p,
.error-state p {
margin-top: 1rem;
color: rgb(var(--color-text-secondary));
}
.error-state button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
color: white;
cursor: pointer;
}
.empty-state {
color: rgb(var(--color-text-secondary));
}
.empty-state h2 {
margin: 1rem 0 0.5rem;
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
}
.empty-state p {
margin: 0 0 1.5rem;
}
.empty-actions {
display: flex;
gap: 0.75rem;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
.action-btn span {
display: none;
}
}
</style>

View file

@ -0,0 +1,457 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { Grid, List, FolderPlus, Upload, ArrowLeft } from 'lucide-svelte';
import { filesStore } from '$lib/stores/files.svelte';
import { toast } from '$lib/stores/toast';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import FileGrid from '$lib/components/files/FileGrid.svelte';
import FileList from '$lib/components/files/FileList.svelte';
import Breadcrumb from '$lib/components/files/Breadcrumb.svelte';
import UploadZone from '$lib/components/files/UploadZone.svelte';
import NewFolderModal from '$lib/components/files/NewFolderModal.svelte';
let showUploadZone = $state(false);
let showNewFolderModal = $state(false);
let uploading = $state(false);
let uploadProgress = $state(0);
let folderId = $derived($page.params.folderId);
// Breadcrumb items from current folder path
let breadcrumbItems = $derived(
filesStore.currentFolder
? [{ id: filesStore.currentFolder.id, name: filesStore.currentFolder.name }]
: []
);
$effect(() => {
if (folderId) {
filesStore.loadFolder(folderId);
}
});
onMount(() => {
filesStore.initViewMode();
});
function handleFolderClick(folder: StorageFolder) {
goto(`/files/${folder.id}`);
}
function handleFileClick(file: StorageFile) {
console.log('File clicked:', file);
}
async function handleFileAction(action: string, file: StorageFile) {
switch (action) {
case 'download':
await filesStore.downloadFile(file.id, file.name);
toast.success('Download gestartet');
break;
case 'rename':
const newName = prompt('Neuer Name:', file.name);
if (newName && newName !== file.name) {
const result = await filesStore.renameFile(file.id, newName);
if (result.error) {
toast.error(result.error);
} else {
toast.success('Datei umbenannt');
}
}
break;
case 'favorite':
const favResult = await filesStore.toggleFileFavorite(file.id);
if (!favResult.error) {
toast.success(file.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
}
break;
case 'delete':
if (confirm('Datei in den Papierkorb verschieben?')) {
const delResult = await filesStore.deleteFile(file.id);
if (delResult.error) {
toast.error(delResult.error);
} else {
toast.success('In den Papierkorb verschoben');
}
}
break;
case 'share':
toast.info('Teilen-Funktion kommt bald');
break;
case 'move':
toast.info('Verschieben-Funktion kommt bald');
break;
}
}
async function handleFolderAction(action: string, folder: StorageFolder) {
switch (action) {
case 'rename':
const newName = prompt('Neuer Name:', folder.name);
if (newName && newName !== folder.name) {
const result = await filesStore.renameFolder(folder.id, newName);
if (result.error) {
toast.error(result.error);
} else {
toast.success('Ordner umbenannt');
}
}
break;
case 'favorite':
const favResult = await filesStore.toggleFolderFavorite(folder.id);
if (!favResult.error) {
toast.success(folder.isFavorite ? 'Favorit entfernt' : 'Als Favorit markiert');
}
break;
case 'delete':
if (confirm('Ordner und Inhalt in den Papierkorb verschieben?')) {
const delResult = await filesStore.deleteFolder(folder.id);
if (delResult.error) {
toast.error(delResult.error);
} else {
toast.success('In den Papierkorb verschoben');
}
}
break;
case 'share':
toast.info('Teilen-Funktion kommt bald');
break;
case 'move':
toast.info('Verschieben-Funktion kommt bald');
break;
}
}
async function handleUpload(files: FileList) {
uploading = true;
uploadProgress = 0;
const totalFiles = files.length;
let completed = 0;
for (const file of files) {
const result = await filesStore.uploadFile(file);
if (result.error) {
toast.error(`Fehler beim Hochladen von ${file.name}: ${result.error}`);
}
completed++;
uploadProgress = Math.round((completed / totalFiles) * 100);
}
uploading = false;
uploadProgress = 0;
showUploadZone = false;
toast.success(`${totalFiles} Datei(en) hochgeladen`);
}
async function handleCreateFolder(name: string, color?: string) {
const result = await filesStore.createFolder(name, color);
if (result.error) {
toast.error(result.error);
} else {
toast.success('Ordner erstellt');
}
}
function handleBreadcrumbNavigate(id: string | null) {
if (id) {
goto(`/files/${id}`);
} else {
goto('/files');
}
}
function goBack() {
const parentId = filesStore.currentFolder?.parentFolderId;
if (parentId) {
goto(`/files/${parentId}`);
} else {
goto('/files');
}
}
</script>
<svelte:head>
<title>{filesStore.currentFolder?.name || 'Ordner'} - Storage</title>
</svelte:head>
<div class="files-page">
<div class="page-header">
<div class="header-left">
<button class="back-btn" onclick={goBack} aria-label="Zurück">
<ArrowLeft size={20} />
</button>
<div>
<h1>{filesStore.currentFolder?.name || 'Ordner'}</h1>
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
</div>
</div>
<div class="header-actions">
<div class="view-toggle">
<button
class="view-btn"
class:active={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<Grid size={18} />
</button>
<button
class="view-btn"
class:active={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
<FolderPlus size={18} />
<span>Neuer Ordner</span>
</button>
<button class="action-btn primary" onclick={() => (showUploadZone = !showUploadZone)}>
<Upload size={18} />
<span>Hochladen</span>
</button>
</div>
</div>
{#if showUploadZone}
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
{/if}
{#if filesStore.loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Laden...</p>
</div>
{:else if filesStore.error}
<div class="error-state">
<p>Fehler: {filesStore.error}</p>
<button onclick={() => filesStore.loadFolder(folderId)}>Erneut versuchen</button>
</div>
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
<div class="empty-state">
<Upload size={48} />
<h2>Leerer Ordner</h2>
<p>Dieser Ordner ist leer. Lade Dateien hoch oder erstelle Unterordner.</p>
<div class="empty-actions">
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
<FolderPlus size={18} />
<span>Neuer Ordner</span>
</button>
<button class="action-btn primary" onclick={() => (showUploadZone = true)}>
<Upload size={18} />
<span>Hochladen</span>
</button>
</div>
</div>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
files={filesStore.files}
folders={filesStore.folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
/>
{:else}
<FileList
files={filesStore.files}
folders={filesStore.folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
onFolderAction={handleFolderAction}
/>
{/if}
</div>
<NewFolderModal
open={showNewFolderModal}
onClose={() => (showNewFolderModal = false)}
onCreate={handleCreateFolder}
/>
<style>
.files-page {
min-height: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.back-btn {
padding: 0.5rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.back-btn:hover {
color: rgb(var(--color-text-primary));
background: rgb(var(--color-surface));
}
.header-left h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.view-toggle {
display: flex;
background: rgb(var(--color-surface));
border-radius: var(--radius-md);
padding: 0.25rem;
}
.view-btn {
padding: 0.5rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn:hover {
color: rgb(var(--color-text-primary));
}
.view-btn.active {
background: rgb(var(--color-surface-elevated));
color: rgb(var(--color-primary));
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
transition: all var(--transition-fast);
}
.action-btn:hover {
background: rgb(var(--color-surface));
}
.action-btn.primary {
background: rgb(var(--color-primary));
border-color: rgb(var(--color-primary));
color: white;
}
.action-btn.primary:hover {
opacity: 0.9;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p,
.error-state p {
margin-top: 1rem;
color: rgb(var(--color-text-secondary));
}
.error-state button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
color: white;
cursor: pointer;
}
.empty-state {
color: rgb(var(--color-text-secondary));
}
.empty-state h2 {
margin: 1rem 0 0.5rem;
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
}
.empty-state p {
margin: 0 0 1.5rem;
}
.empty-actions {
display: flex;
gap: 0.75rem;
}
@media (max-width: 640px) {
.page-header {
flex-direction: column;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
.action-btn span {
display: none;
}
}
</style>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ManaIcon } from '@manacore/shared-branding';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleResetPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>{translations.title} - Storage</title>
</svelte:head>
<ForgotPasswordPage
appName="Storage"
logo={ManaIcon}
primaryColor="#3b82f6"
onResetPassword={handleResetPassword}
{goto}
loginPath="/login"
lightBackground="#eff6ff"
darkBackground="#0f172a"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
</ForgotPasswordPage>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { ManaIcon } from '@manacore/shared-branding';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
const translations = $derived(getLoginTranslations($locale || 'de'));
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<svelte:head>
<title>{translations.title} - Storage</title>
</svelte:head>
<LoginPage
appName="Storage"
logo={ManaIcon}
primaryColor="#3b82f6"
onSignIn={handleSignIn}
{goto}
successRedirect="/files"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#eff6ff"
darkBackground="#0f172a"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { User } from 'lucide-svelte';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Profil - Storage</title>
</svelte:head>
<div class="profile-page">
<div class="page-header">
<h1>
<User size={24} />
Profil
</h1>
</div>
<div class="profile-card">
<div class="avatar">
<User size={48} />
</div>
<div class="profile-info">
<h2>{authStore.user?.email || 'Nicht angemeldet'}</h2>
<p>Mitglied seit 2024</p>
</div>
</div>
<div class="profile-section">
<h3>Kontoinformationen</h3>
<div class="info-row">
<span class="label">E-Mail</span>
<span class="value">{authStore.user?.email || '—'}</span>
</div>
<div class="info-row">
<span class="label">Benutzer-ID</span>
<span class="value">{authStore.user?.id || '—'}</span>
</div>
</div>
</div>
<style>
.profile-page {
min-height: 100%;
max-width: 600px;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.profile-card {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 2rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-xl);
margin-bottom: 2rem;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: rgb(var(--color-surface));
border-radius: 50%;
color: rgb(var(--color-text-secondary));
}
.profile-info h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.profile-info p {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.profile-section {
padding: 1.5rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-xl);
}
.profile-section h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid rgb(var(--color-border));
}
.info-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.label {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.value {
font-size: 0.875rem;
color: rgb(var(--color-text-primary));
font-family: monospace;
}
</style>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { ManaIcon } from '@manacore/shared-branding';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>{translations.title} - Storage</title>
</svelte:head>
<RegisterPage
appName="Storage"
logo={ManaIcon}
primaryColor="#3b82f6"
onSignUp={handleSignUp}
{goto}
successRedirect="/files"
loginPath="/login"
lightBackground="#eff6ff"
darkBackground="#0f172a"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
</RegisterPage>

View file

@ -0,0 +1,285 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { Search, Grid, List } from 'lucide-svelte';
import { searchApi } from '$lib/api/client';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import { filesStore } from '$lib/stores/files.svelte';
import FileGrid from '$lib/components/files/FileGrid.svelte';
import FileList from '$lib/components/files/FileList.svelte';
let query = $state('');
let files = $state<StorageFile[]>([]);
let folders = $state<StorageFolder[]>([]);
let loading = $state(false);
let searched = $state(false);
// Get initial query from URL
let initialQuery = $derived($page.url.searchParams.get('q') || '');
onMount(() => {
filesStore.initViewMode();
if (initialQuery) {
query = initialQuery;
handleSearch();
}
});
async function handleSearch() {
if (!query.trim()) return;
loading = true;
searched = true;
const result = await searchApi.search(query.trim());
if (result.data) {
files = result.data.files;
folders = result.data.folders;
}
loading = false;
// Update URL
const url = new URL(window.location.href);
url.searchParams.set('q', query.trim());
window.history.replaceState({}, '', url.toString());
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleSearch();
}
}
function handleFolderClick(folder: StorageFolder) {
goto(`/files/${folder.id}`);
}
function handleFileClick(file: StorageFile) {
console.log('File clicked:', file);
}
</script>
<svelte:head>
<title>Suche - Storage</title>
</svelte:head>
<div class="search-page">
<div class="page-header">
<h1>
<Search size={24} />
Suche
</h1>
<div class="view-toggle">
<button
class="view-btn"
class:active={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<Grid size={18} />
</button>
<button
class="view-btn"
class:active={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
</div>
<div class="search-bar">
<Search size={20} />
<input
type="text"
bind:value={query}
onkeydown={handleKeydown}
placeholder="Dateien und Ordner durchsuchen..."
autofocus
/>
<button onclick={handleSearch} disabled={!query.trim() || loading}>
{loading ? 'Suche...' : 'Suchen'}
</button>
</div>
{#if loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Suche läuft...</p>
</div>
{:else if searched && files.length === 0 && folders.length === 0}
<div class="empty-state">
<Search size={48} />
<h2>Keine Ergebnisse</h2>
<p>Keine Dateien oder Ordner für "{query}" gefunden.</p>
</div>
{:else if searched}
<div class="results-header">
<span>{files.length + folders.length} Ergebnis(se) für "{query}"</span>
</div>
{#if filesStore.viewMode === 'grid'}
<FileGrid {files} {folders} onFileClick={handleFileClick} onFolderClick={handleFolderClick} />
{:else}
<FileList {files} {folders} onFileClick={handleFileClick} onFolderClick={handleFolderClick} />
{/if}
{:else}
<div class="empty-state">
<Search size={48} />
<h2>Dateien durchsuchen</h2>
<p>Gib einen Suchbegriff ein, um Dateien und Ordner zu finden.</p>
</div>
{/if}
</div>
<style>
.search-page {
min-height: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.view-toggle {
display: flex;
background: rgb(var(--color-surface));
border-radius: var(--radius-md);
padding: 0.25rem;
}
.view-btn {
padding: 0.5rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all var(--transition-fast);
}
.view-btn:hover {
color: rgb(var(--color-text-primary));
}
.view-btn.active {
background: rgb(var(--color-surface-elevated));
color: rgb(var(--color-primary));
}
.search-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
}
.search-bar :global(svg) {
color: rgb(var(--color-text-secondary));
flex-shrink: 0;
}
.search-bar input {
flex: 1;
background: transparent;
border: none;
font-size: 1rem;
color: rgb(var(--color-text-primary));
outline: none;
}
.search-bar input::placeholder {
color: rgb(var(--color-text-tertiary));
}
.search-bar button {
padding: 0.5rem 1rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
color: white;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.search-bar button:hover:not(:disabled) {
opacity: 0.9;
}
.search-bar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.results-header {
margin-bottom: 1rem;
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
margin-top: 1rem;
color: rgb(var(--color-text-secondary));
}
.empty-state {
color: rgb(var(--color-text-secondary));
}
.empty-state h2 {
margin: 1rem 0 0.5rem;
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
}
.empty-state p {
margin: 0;
}
</style>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
SettingsPage,
SettingsSection,
SettingsCard,
SettingsRow,
GlobalSettingsSection,
} from '@manacore/shared-ui';
onMount(async () => {
await userSettings.load();
});
</script>
<svelte:head>
<title>Einstellungen - Storage</title>
</svelte:head>
<SettingsPage title="Einstellungen" subtitle="Passe die App an deine Vorlieben an.">
<!-- Appearance Section -->
<SettingsSection title="Darstellung">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
{/snippet}
<SettingsCard>
<div class="px-5 py-4">
<p class="font-medium text-[hsl(var(--foreground))] mb-2">Farbmodus</p>
<p class="text-sm text-[hsl(var(--muted-foreground))] mb-4">
Wähle zwischen Hell, Dunkel oder System
</p>
<div class="flex gap-2">
{#each ['light', 'dark', 'system'] as mode}
<button
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors {theme.mode === mode
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] hover:bg-[hsl(var(--muted))]/80 text-[hsl(var(--foreground))]'}"
onclick={() => theme.setMode(mode as 'light' | 'dark' | 'system')}
>
{mode === 'light' ? 'Hell' : mode === 'dark' ? 'Dunkel' : 'System'}
</button>
{/each}
</div>
</div>
<div class="px-5 py-4 border-t border-[hsl(var(--border))]">
<p class="font-medium text-[hsl(var(--foreground))] mb-2">Theme</p>
<p class="text-sm text-[hsl(var(--muted-foreground))] mb-4">Wähle eine Farbpalette</p>
<div class="flex gap-2 flex-wrap">
{#each theme.variants as variant}
<button
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors {theme.variant ===
variant
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] hover:bg-[hsl(var(--muted))]/80 text-[hsl(var(--foreground))]'}"
onclick={() => theme.setVariant(variant)}
>
{THEME_DEFINITIONS[variant].label}
</button>
{/each}
</div>
</div>
</SettingsCard>
</SettingsSection>
<!-- Storage Section -->
<SettingsSection title="Speicher">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
{/snippet}
<SettingsCard>
<div class="px-5 py-4">
<p class="font-medium text-[hsl(var(--foreground))] mb-2">Speicherplatz</p>
<p class="text-sm text-[hsl(var(--muted-foreground))] mb-4">Dein genutzter Speicherplatz</p>
<div class="w-full h-2 bg-[hsl(var(--muted))] rounded-full overflow-hidden mb-2">
<div class="h-full bg-[hsl(var(--primary))] rounded-full" style="width: 25%"></div>
</div>
<p class="text-sm text-[hsl(var(--muted-foreground))]">2.5 GB von 10 GB verwendet</p>
</div>
</SettingsCard>
</SettingsSection>
<!-- Global Settings Section -->
<GlobalSettingsSection {userSettings} />
<!-- About Section -->
<SettingsSection title="Über">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsRow label="Version" border={false}>
<span class="text-[hsl(var(--muted-foreground))]">1.0.0</span>
</SettingsRow>
</SettingsCard>
</SettingsSection>
</SettingsPage>

View file

@ -0,0 +1,337 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Share2, Link, Copy, Trash2 } from 'lucide-svelte';
import { sharesApi } from '$lib/api/client';
import type { Share } from '$lib/api/client';
import { toast } from '$lib/stores/toast';
let shares = $state<Share[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
await loadShares();
});
async function loadShares() {
loading = true;
error = null;
const result = await sharesApi.list();
if (result.error) {
error = result.error;
} else if (result.data) {
shares = result.data;
}
loading = false;
}
async function copyShareLink(token: string) {
const url = `${window.location.origin}/s/${token}`;
await navigator.clipboard.writeText(url);
toast.success('Link kopiert!');
}
async function deleteShare(id: string) {
if (!confirm('Share-Link wirklich löschen?')) return;
const result = await sharesApi.delete(id);
if (result.error) {
toast.error(result.error);
} else {
shares = shares.filter((s) => s.id !== id);
toast.success('Share-Link gelöscht');
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'Kein Ablaufdatum';
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function getAccessLevelLabel(level: string): string {
switch (level) {
case 'view':
return 'Ansehen';
case 'download':
return 'Herunterladen';
case 'edit':
return 'Bearbeiten';
default:
return level;
}
}
</script>
<svelte:head>
<title>Geteilt - Storage</title>
</svelte:head>
<div class="shared-page">
<div class="page-header">
<h1>
<Share2 size={24} />
Geteilte Links
</h1>
</div>
{#if loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Laden...</p>
</div>
{:else if error}
<div class="error-state">
<p>Fehler: {error}</p>
<button onclick={loadShares}>Erneut versuchen</button>
</div>
{:else if shares.length === 0}
<div class="empty-state">
<Share2 size={48} />
<h2>Keine geteilten Links</h2>
<p>Teile Dateien oder Ordner, um Links hier zu verwalten.</p>
</div>
{:else}
<div class="shares-list">
{#each shares as share (share.id)}
<div class="share-item">
<div class="share-info">
<div class="share-icon">
<Link size={20} />
</div>
<div class="share-details">
<span class="share-type">
{share.shareType === 'file' ? 'Datei' : 'Ordner'}
</span>
<div class="share-meta">
<span class="badge">{getAccessLevelLabel(share.accessLevel)}</span>
{#if share.password}
<span class="badge secure">Passwortgeschützt</span>
{/if}
{#if share.maxDownloads}
<span class="badge">{share.downloadCount}/{share.maxDownloads} Downloads</span>
{/if}
</div>
<span class="share-expires">
{share.expiresAt ? `Läuft ab: ${formatDate(share.expiresAt)}` : 'Kein Ablaufdatum'}
</span>
</div>
</div>
<div class="share-actions">
<button class="copy-btn" onclick={() => copyShareLink(share.shareToken)}>
<Copy size={16} />
Link kopieren
</button>
<button class="delete-btn" onclick={() => deleteShare(share.id)}>
<Trash2 size={16} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.shared-page {
min-height: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p,
.error-state p {
margin-top: 1rem;
color: rgb(var(--color-text-secondary));
}
.error-state button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
color: white;
cursor: pointer;
}
.empty-state {
color: rgb(var(--color-text-secondary));
}
.empty-state h2 {
margin: 1rem 0 0.5rem;
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
}
.empty-state p {
margin: 0;
}
.shares-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.share-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
}
.share-info {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.share-icon {
padding: 0.5rem;
background: rgb(var(--color-surface));
border-radius: var(--radius-md);
color: rgb(var(--color-primary));
}
.share-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.share-type {
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
}
.share-meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
padding: 0.125rem 0.5rem;
background: rgb(var(--color-surface));
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: rgb(var(--color-text-secondary));
}
.badge.secure {
background: rgba(var(--color-success), 0.1);
color: rgb(var(--color-success));
}
.share-expires {
font-size: 0.75rem;
color: rgb(var(--color-text-tertiary));
}
.share-actions {
display: flex;
gap: 0.5rem;
}
.copy-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
font-size: 0.75rem;
color: white;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.copy-btn:hover {
opacity: 0.9;
}
.delete-btn {
padding: 0.5rem;
background: transparent;
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
color: rgb(var(--color-error));
cursor: pointer;
transition: all var(--transition-fast);
}
.delete-btn:hover {
background: rgba(var(--color-error), 0.1);
border-color: rgb(var(--color-error));
}
@media (max-width: 640px) {
.share-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.share-actions {
width: 100%;
}
.copy-btn {
flex: 1;
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { Palette, Check } from 'lucide-svelte';
import { theme } from '$lib/stores/theme.svelte';
import { THEME_DEFINITIONS, THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
</script>
<svelte:head>
<title>Themes - Storage</title>
</svelte:head>
<div class="themes-page">
<div class="page-header">
<h1>
<Palette size={24} />
Themes
</h1>
</div>
<div class="themes-grid">
{#each THEME_VARIANTS as variant}
{@const def = THEME_DEFINITIONS[variant]}
<button
class="theme-card"
class:active={theme.variant === variant}
onclick={() => theme.setVariant(variant)}
>
<div
class="theme-preview"
style="background: linear-gradient(135deg, {def.colors.primary}, {def.colors.accent})"
>
{#if theme.variant === variant}
<div class="check-badge">
<Check size={16} />
</div>
{/if}
</div>
<div class="theme-info">
<span class="theme-name">{def.label}</span>
<span class="theme-icon">{def.icon}</span>
</div>
</button>
{/each}
</div>
</div>
<style>
.themes-page {
min-height: 100%;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.themes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.theme-card {
position: relative;
padding: 0;
background: rgb(var(--color-surface-elevated));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-xl);
overflow: hidden;
cursor: pointer;
transition: all var(--transition-fast);
}
.theme-card:hover {
border-color: rgb(var(--color-primary));
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.theme-card.active {
border-color: rgb(var(--color-primary));
}
.theme-preview {
position: relative;
height: 100px;
}
.check-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: white;
border-radius: 50%;
color: rgb(var(--color-primary));
box-shadow: var(--shadow-md);
}
.theme-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
.theme-name {
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
}
.theme-icon {
font-size: 1.25rem;
}
@media (max-width: 480px) {
.themes-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -0,0 +1,359 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Trash2, RotateCcw, AlertTriangle } from 'lucide-svelte';
import { trashApi } from '$lib/api/client';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import { toast } from '$lib/stores/toast';
let files = $state<StorageFile[]>([]);
let folders = $state<StorageFolder[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
await loadTrash();
});
async function loadTrash() {
loading = true;
error = null;
const result = await trashApi.list();
if (result.error) {
error = result.error;
} else if (result.data) {
files = result.data.files;
folders = result.data.folders;
}
loading = false;
}
async function handleRestore(id: string, type: 'file' | 'folder') {
const result = await trashApi.restore(id, type);
if (result.error) {
toast.error(result.error);
} else {
if (type === 'file') {
files = files.filter((f) => f.id !== id);
} else {
folders = folders.filter((f) => f.id !== id);
}
toast.success('Wiederhergestellt');
}
}
async function handlePermanentDelete(id: string, type: 'file' | 'folder') {
if (!confirm('Endgültig löschen? Dies kann nicht rückgängig gemacht werden.')) return;
const result = await trashApi.permanentDelete(id, type);
if (result.error) {
toast.error(result.error);
} else {
if (type === 'file') {
files = files.filter((f) => f.id !== id);
} else {
folders = folders.filter((f) => f.id !== id);
}
toast.success('Endgültig gelöscht');
}
}
async function handleEmptyTrash() {
if (!confirm('Papierkorb leeren? Alle Elemente werden endgültig gelöscht.')) return;
const result = await trashApi.empty();
if (result.error) {
toast.error(result.error);
} else {
files = [];
folders = [];
toast.success('Papierkorb geleert');
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Papierkorb - Storage</title>
</svelte:head>
<div class="trash-page">
<div class="page-header">
<h1>
<Trash2 size={24} />
Papierkorb
</h1>
{#if files.length > 0 || folders.length > 0}
<button class="empty-btn" onclick={handleEmptyTrash}>
<AlertTriangle size={16} />
Papierkorb leeren
</button>
{/if}
</div>
{#if loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Laden...</p>
</div>
{:else if error}
<div class="error-state">
<p>Fehler: {error}</p>
<button onclick={loadTrash}>Erneut versuchen</button>
</div>
{:else if files.length === 0 && folders.length === 0}
<div class="empty-state">
<Trash2 size={48} />
<h2>Papierkorb ist leer</h2>
<p>Gelöschte Dateien und Ordner erscheinen hier.</p>
</div>
{:else}
<div class="trash-list">
{#each folders as folder (folder.id)}
<div class="trash-item">
<div class="item-info">
<span class="item-icon folder">📁</span>
<div class="item-details">
<span class="item-name">{folder.name}</span>
<span class="item-meta">Gelöscht am {formatDate(folder.deletedAt)}</span>
</div>
</div>
<div class="item-actions">
<button class="restore-btn" onclick={() => handleRestore(folder.id, 'folder')}>
<RotateCcw size={16} />
Wiederherstellen
</button>
<button class="delete-btn" onclick={() => handlePermanentDelete(folder.id, 'folder')}>
Endgültig löschen
</button>
</div>
</div>
{/each}
{#each files as file (file.id)}
<div class="trash-item">
<div class="item-info">
<span class="item-icon file">📄</span>
<div class="item-details">
<span class="item-name">{file.name}</span>
<span class="item-meta">Gelöscht am {formatDate(file.deletedAt)}</span>
</div>
</div>
<div class="item-actions">
<button class="restore-btn" onclick={() => handleRestore(file.id, 'file')}>
<RotateCcw size={16} />
Wiederherstellen
</button>
<button class="delete-btn" onclick={() => handlePermanentDelete(file.id, 'file')}>
Endgültig löschen
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.trash-page {
min-height: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.empty-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-error));
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
color: white;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.empty-btn:hover {
opacity: 0.9;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.loading-state .spinner {
width: 40px;
height: 40px;
border: 3px solid rgb(var(--color-border));
border-top-color: rgb(var(--color-primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p,
.error-state p {
margin-top: 1rem;
color: rgb(var(--color-text-secondary));
}
.error-state button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: rgb(var(--color-primary));
border: none;
border-radius: var(--radius-md);
color: white;
cursor: pointer;
}
.empty-state {
color: rgb(var(--color-text-secondary));
}
.empty-state h2 {
margin: 1rem 0 0.5rem;
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
}
.empty-state p {
margin: 0;
}
.trash-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.trash-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
}
.item-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.item-icon {
font-size: 1.5rem;
}
.item-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-name {
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
}
.item-meta {
font-size: 0.75rem;
color: rgb(var(--color-text-secondary));
}
.item-actions {
display: flex;
gap: 0.5rem;
}
.restore-btn,
.delete-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.75rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.restore-btn {
background: rgb(var(--color-surface));
border: 1px solid rgb(var(--color-border));
color: rgb(var(--color-text-primary));
}
.restore-btn:hover {
border-color: rgb(var(--color-primary));
color: rgb(var(--color-primary));
}
.delete-btn {
background: transparent;
border: none;
color: rgb(var(--color-error));
}
.delete-btn:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.trash-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.item-actions {
width: 100%;
}
.restore-btn {
flex: 1;
justify-content: center;
}
}
</style>