init new frontend

This commit is contained in:
Bradley Nelson
2026-06-17 17:06:30 -06:00
parent b8a0018785
commit daa3010458
114 changed files with 32716 additions and 119 deletions
@@ -0,0 +1,398 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Icon from '$lib/icons/Icon.svelte';
import {
createFolder,
deleteFolder,
getFolder,
listFolder,
renameFolder,
type FolderListing
} from '$lib/api/endpoints/folders';
import {
deleteFile,
fileDownloadUrl,
fileInlineUrl,
renameFile,
uploadFile
} from '$lib/api/endpoints/files';
import type { FileItem, FolderItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
// The URL rest param is the trail of folder ids from home's children down.
// /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
let listing = $state<FolderListing>({ folders: [], files: [] });
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
let dragOver = $state(false);
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
// Names for each id in the trail; tolerate failures with a fallback label.
const metas = await Promise.all(
segments.map((id) =>
getFolder(id)
.then((f) => ({ id, name: f.name }))
.catch(() => ({ id, name: '…' }))
)
);
return metas;
}
async function load() {
loading = true;
error = null;
try {
// External users have no home folder; send them to shared-with-me.
if (session.isExternalUser && pathSegments.length === 0) {
await goto('/shared-with-me', { replaceState: true });
return;
}
const home = await session.loadHomeFolder();
const folderId = pathSegments.at(-1) ?? home;
if (!folderId) {
error = t('files.no_home', 'No home folder available.');
return;
}
currentId = folderId;
filesStore.currentFolder = folderId;
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
listing = data;
crumbs = trail;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function openFolder(folder: FolderItem) {
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
}
function crumbHref(index: number): string {
return `/files/${pathSegments.slice(0, index + 1).join('/')}`;
}
async function onNewFolder() {
const name = prompt(t('files.new_folder_prompt', 'New folder name'));
if (!name) return;
try {
await createFolder(name, currentId);
await load();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
uploading = true;
try {
for (const file of Array.from(input.files)) {
await uploadFile(currentId, file);
}
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
await load();
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
uploading = false;
input.value = '';
}
}
async function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const dropped = e.dataTransfer?.files;
if (!dropped?.length) return;
uploading = true;
try {
for (const file of Array.from(dropped)) await uploadFile(currentId, file);
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
await load();
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
uploading = false;
}
}
async function renameItem(kind: 'file' | 'folder', id: string, current: string) {
const name = prompt(t('files.rename_prompt', 'New name'), current);
if (!name || name === current) return;
try {
if (kind === 'file') await renameFile(id, name);
else await renameFolder(id, name);
await load();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function deleteItem(kind: 'file' | 'folder', id: string, name: string) {
if (!confirm(t('files.confirm_delete', { name }, 'Move "{{name}}" to trash?'))) return;
try {
if (kind === 'file') await deleteFile(id);
else await deleteFolder(id);
await load();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function openFile(file: FileItem) {
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
}
const isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0);
const viewClass = $derived(
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
);
// Reload whenever the route path changes.
$effect(() => {
// reference pathSegments so the effect re-runs on navigation
void pathSegments;
void load();
});
</script>
<svelte:head><title>{t('nav.files', 'Files')} · OxiCloud</title></svelte:head>
<div
class="files-page"
class:dropzone-active={dragOver}
role="region"
aria-label={t('nav.files', 'Files')}
ondragover={(e) => {
e.preventDefault();
dragOver = true;
}}
ondragleave={() => (dragOver = false)}
ondrop={onDrop}
>
<div class="page-sticky-header">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/files" class="breadcrumb-item">
<Icon name="folder" />
{session.homeFolderName ?? t('nav.files', 'Files')}
</a>
{#each crumbs as c, i (c.id)}
<span class="breadcrumb-separator">/</span>
<a href={crumbHref(i)} class="breadcrumb-item">{c.name}</a>
{/each}
</nav>
<div class="actions-bar">
<div class="action-buttons">
<button class="btn btn-secondary" onclick={onNewFolder}>
<Icon name="folder-plus" />
{t('files.new_folder', 'New folder')}
</button>
<button class="btn btn-primary" onclick={() => fileInput?.click()} disabled={uploading}>
<Icon name="arrow-up" />
{uploading ? t('files.uploading', 'Uploading…') : t('files.upload', 'Upload')}
</button>
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} />
</div>
<div class="view-toggle" role="group" aria-label="View mode">
<button
class="view-toggle-btn"
class:active={filesStore.viewMode === 'grid'}
aria-pressed={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}
>
<Icon name="th" />
</button>
<button
class="view-toggle-btn"
class:active={filesStore.viewMode === 'list'}
aria-pressed={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}
>
<Icon name="list" />
</button>
</div>
</div>
</div>
{#if error}
<div class="empty-state"><p>{error}</p></div>
{:else if loading && isEmpty}
<div class="empty-state"><p>{t('common.loading', 'Loading…')}</p></div>
{:else if isEmpty}
<div class="empty-state">
<p>{t('files.empty_title', 'This folder is empty')}</p>
<p>{t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}</p>
</div>
{:else}
<div class="files-container">
<div
class={viewClass}
style="--files-list-columns: minmax(200px, 2fr) 120px 110px 140px 110px"
>
<div class="list-header">
<div>{t('files.col_name', 'Name')}</div>
<div>{t('files.col_type', 'Type')}</div>
<div>{t('files.col_size', 'Size')}</div>
<div>{t('files.col_modified', 'Modified')}</div>
<div></div>
</div>
{#each listing.folders as folder (folder.id)}
<div
class="file-item"
role="button"
tabindex="0"
ondblclick={() => openFolder(folder)}
onclick={() => openFolder(folder)}
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
>
<div class="name-cell">
<span class="file-icon"><Icon name="folder" /></span>
<span>{folder.name}</span>
</div>
<div class="type-cell">{t('files.folder', 'Folder')}</div>
<div class="size-cell">—</div>
<div class="date-cell">{formatDate(folder.modified_at)}</div>
<div class="grid-meta"></div>
<div class="action-cell">
<button
class="btn-action"
title={t('common.rename', 'Rename')}
onclick={(e) => {
e.stopPropagation();
renameItem('folder', folder.id, folder.name);
}}><Icon name="pen" /></button
>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={(e) => {
e.stopPropagation();
deleteItem('folder', folder.id, folder.name);
}}><Icon name="trash" /></button
>
</div>
</div>
{/each}
{#each listing.files as file (file.id)}
<div
class="file-item"
role="button"
tabindex="0"
ondblclick={() => openFile(file)}
onclick={() => openFile(file)}
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
>
<div class="name-cell">
<span class="file-icon"><Icon name={iconNameFromClass(file.icon_class)} /></span>
<span>{file.name}</span>
</div>
<div class="type-cell">{file.category || t('files.file', 'File')}</div>
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
<div class="date-cell">{formatDate(file.modified_at)}</div>
<div class="grid-meta">
{#if file.size != null}<span class="grid-meta__size">{formatBytes(file.size)}</span
>{/if}
</div>
<div class="action-cell">
<a
class="btn-action"
href={fileDownloadUrl(file.id)}
download
title={t('common.download', 'Download')}
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
>
<button
class="btn-action"
title={t('common.rename', 'Rename')}
onclick={(e) => {
e.stopPropagation();
renameItem('file', file.id, file.name);
}}><Icon name="pen" /></button
>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={(e) => {
e.stopPropagation();
deleteItem('file', file.id, file.name);
}}><Icon name="trash" /></button
>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.files-page {
min-height: 100%;
}
.files-page.dropzone-active {
outline: 2px dashed var(--color-accent);
outline-offset: -8px;
border-radius: var(--radius-xl);
}
.page-sticky-header {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
flex: none;
}
.view-toggle-btn {
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.view-toggle-btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.action-cell {
display: flex;
gap: var(--space-1);
justify-content: flex-end;
}
.btn-action--delete:hover {
color: var(--color-danger-text);
}
.btn-action {
text-decoration: none;
}
</style>