mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 06:06:42 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
274
apps-archived/storage/apps/web/src/lib/api/client.ts
Normal file
274
apps-archived/storage/apps/web/src/lib/api/client.ts
Normal 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'),
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
49
apps-archived/storage/apps/web/src/lib/i18n/index.ts
Normal file
49
apps-archived/storage/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
77
apps-archived/storage/apps/web/src/lib/i18n/locales/de.json
Normal file
77
apps-archived/storage/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
77
apps-archived/storage/apps/web/src/lib/i18n/locales/en.json
Normal file
77
apps-archived/storage/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
159
apps-archived/storage/apps/web/src/lib/stores/auth.svelte.ts
Normal file
159
apps-archived/storage/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
173
apps-archived/storage/apps/web/src/lib/stores/files.svelte.ts
Normal file
173
apps-archived/storage/apps/web/src/lib/stores/files.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
63
apps-archived/storage/apps/web/src/lib/stores/toast.ts
Normal file
63
apps-archived/storage/apps/web/src/lib/stores/toast.ts
Normal 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();
|
||||
|
|
@ -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(),
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue