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
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import '$lib/styles/app.css';
import AppShell from '$lib/components/AppShell.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { session } from '$lib/stores/session.svelte';
import { legacyHashToPath } from '$lib/utils/legacyHash';
let { children } = $props();
// Routes reachable without an authenticated session.
const PUBLIC_PREFIXES = ['/login', '/device', '/s/', '/nextcloud'];
function isPublic(pathname: string): boolean {
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(p));
}
let ready = $state(false);
onMount(async () => {
// Redirect legacy `#/...` bookmarks to the new path before anything else.
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
const mapped = legacyHashToPath(location.hash);
if (mapped) await goto(mapped, { replaceState: true });
}
await session.load();
ready = true;
});
// Guard: once the session is known, bounce unauthenticated users off
// protected routes. Runs client-side only (ssr=false).
$effect(() => {
if (!ready) return;
const path = page.url.pathname;
if (!session.isAuthenticated && !isPublic(path)) {
void goto(`/login?redirect=${encodeURIComponent(path)}`, { replaceState: true });
}
});
</script>
{#if isPublic(page.url.pathname)}
{@render children()}
{:else if ready && session.isAuthenticated}
<AppShell {children} />
{:else if ready}
{@render children()}
{:else}
<div class="app-loading" aria-busy="true">Loading…</div>
{/if}
<Toaster />
<style>
.app-loading {
display: grid;
place-items: center;
min-height: 100vh;
color: var(--color-text-muted);
}
</style>
+6
View File
@@ -0,0 +1,6 @@
// Pure client-rendered SPA: no SSR, no prerendering. The static adapter emits a
// single `index.html` fallback that the Rust web layer serves for every client
// route (including deep links like /s/<token> and /files/<path>).
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'never';
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// The app root redirects to the primary files view.
onMount(() => {
void goto('/files', { replaceState: true });
});
</script>
<p>Loading…</p>
+452
View File
@@ -0,0 +1,452 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
createUser,
deletePlugin,
deleteUser,
listPlugins,
listUsers,
resetUserPassword,
setPluginEnabled,
setUserActive,
setUserQuota,
setUserRole,
type PluginInfo
} from '$lib/api/endpoints/admin';
import type { User } from '$lib/api/types';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
const PAGE_SIZE = 25;
let tab = $state<'users' | 'plugins'>('users');
// Users
let users = $state<User[]>([]);
let total = $state(0);
let pageIndex = $state(0);
let usersError = $state<string | null>(null);
let createOpen = $state(false);
let newUser = $state({ username: '', email: '', password: '', role: 'user', quotaGb: 5 });
// Plugins
let plugins = $state<PluginInfo[]>([]);
let pluginsAvailable = $state(true);
let pluginsError = $state<string | null>(null);
async function loadUsers() {
usersError = null;
try {
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
users = page.users;
total = page.total;
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
async function loadPlugins() {
pluginsError = null;
try {
const res = await listPlugins();
pluginsAvailable = res.available;
plugins = res.plugins;
} catch (e) {
pluginsError = e instanceof Error ? e.message : String(e);
}
}
function reportError(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
async function toggleRole(u: User) {
const role = u.role === 'admin' ? 'user' : 'admin';
if (!confirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?'))) return;
try {
await setUserRole(u.id, role);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function toggleActive(u: User) {
try {
await setUserActive(u.id, !u.active);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function changeQuota(u: User) {
const gb = prompt(t('admin.quota_prompt', 'Quota in GB (0 = unlimited)'));
if (gb === null) return;
try {
await setUserQuota(u.id, Math.round(Number(gb) * 1024 ** 3));
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function resetPw(u: User) {
const pw = prompt(t('admin.new_password_prompt', 'New password'));
if (!pw) return;
try {
await resetUserPassword(u.id, pw);
ui.notify(t('admin.password_reset', 'Password reset'), 'success');
} catch (e) {
reportError(e);
}
}
async function removeUser(u: User) {
if (!confirm(t('admin.confirm_delete_user', { name: u.email }, 'Delete user {{name}}?')))
return;
try {
await deleteUser(u.id);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function submitCreate(e: SubmitEvent) {
e.preventDefault();
try {
await createUser({
username: newUser.username,
email: newUser.email,
password: newUser.password,
role: newUser.role,
quota_bytes: Math.round(newUser.quotaGb * 1024 ** 3)
});
createOpen = false;
newUser = { username: '', email: '', password: '', role: 'user', quotaGb: 5 };
await loadUsers();
} catch (err) {
reportError(err);
}
}
async function togglePlugin(p: PluginInfo) {
try {
await setPluginEnabled(p.id, !p.enabled);
await loadPlugins();
} catch (e) {
reportError(e);
}
}
async function removePlugin(p: PluginInfo) {
if (!confirm(t('admin.confirm_delete_plugin', { name: p.name }, 'Delete plugin {{name}}?')))
return;
try {
await deletePlugin(p.id);
await loadPlugins();
} catch (e) {
reportError(e);
}
}
function changePage(delta: number) {
const next = pageIndex + delta;
if (next < 0 || next * PAGE_SIZE >= total) return;
pageIndex = next;
void loadUsers();
}
onMount(() => {
void loadUsers();
void loadPlugins();
});
</script>
<svelte:head><title>{t('admin.title', 'Admin')} · OxiCloud</title></svelte:head>
<main class="admin">
<h1>{t('admin.title', 'Admin')}</h1>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
{t('admin.users', 'Users')}
</button>
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
{t('admin.plugins', 'Plugins')}
</button>
</div>
{#if tab === 'users'}
<div class="bar">
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
{t('admin.create_user', 'Create user')}
</button>
</div>
{#if usersError}
<p class="status status--error">{usersError}</p>
{:else}
<table class="table">
<thead>
<tr>
<th>{t('admin.user', 'User')}</th>
<th>{t('admin.role', 'Role')}</th>
<th>{t('admin.status', 'Status')}</th>
<th>{t('admin.quota', 'Quota')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>
<div class="user-cell">
<strong>{u.username || u.email}</strong>
<span class="muted">{u.email}</span>
</div>
</td>
<td>{u.role}</td>
<td>{u.active ? t('admin.active', 'Active') : t('admin.inactive', 'Inactive')}</td>
<td>
{u.storage_quota_bytes > 0 ? formatBytes(u.storage_quota_bytes) : '∞'}
</td>
<td class="actions">
<button class="link-btn" onclick={() => toggleRole(u)}
>{t('admin.role', 'Role')}</button
>
<button class="link-btn" onclick={() => toggleActive(u)}>
{u.active ? t('admin.deactivate', 'Deactivate') : t('admin.activate', 'Activate')}
</button>
<button class="link-btn" onclick={() => changeQuota(u)}
>{t('admin.quota', 'Quota')}</button
>
<button class="link-btn" onclick={() => resetPw(u)}
>{t('admin.reset_pw', 'Reset pw')}</button
>
<button class="link-btn link-btn--danger" onclick={() => removeUser(u)}>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
<div class="pager">
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
<button
class="btn"
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
onclick={() => changePage(1)}>›</button
>
</div>
{/if}
{:else if !pluginsAvailable}
<p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p>
{:else if pluginsError}
<p class="status status--error">{pluginsError}</p>
{:else if plugins.length === 0}
<p class="status">{t('admin.no_plugins', 'No plugins installed.')}</p>
{:else}
<table class="table">
<thead>
<tr>
<th>{t('admin.plugin', 'Plugin')}</th>
<th>{t('admin.version', 'Version')}</th>
<th>{t('admin.status', 'Status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each plugins as p (p.id)}
<tr>
<td>
<div class="user-cell">
<strong>{p.name}</strong>
{#if p.description}<span class="muted">{p.description}</span>{/if}
</div>
</td>
<td>{p.version ?? '—'}</td>
<td>{p.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}</td>
<td class="actions">
<button class="link-btn" onclick={() => togglePlugin(p)}>
{p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
</button>
<button class="link-btn link-btn--danger" onclick={() => removePlugin(p)}>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</main>
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
<form id="create-user-form" onsubmit={submitCreate} class="form">
<label
><span>{t('admin.username', 'Username')}</span>
<input bind:value={newUser.username} required /></label
>
<label
><span>{t('admin.email', 'Email')}</span>
<input type="email" bind:value={newUser.email} required /></label
>
<label
><span>{t('admin.password', 'Password')}</span>
<input type="password" bind:value={newUser.password} required /></label
>
<label
><span>{t('admin.role', 'Role')}</span>
<select bind:value={newUser.role}>
<option value="user">user</option>
<option value="admin">admin</option>
</select></label
>
<label
><span>{t('admin.quota_gb', 'Quota (GB)')}</span>
<input type="number" min="0" bind:value={newUser.quotaGb} /></label
>
</form>
{#snippet footer()}
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="create-user-form">
{t('common.create', 'Create')}
</button>
{/snippet}
</Modal>
<style>
.admin {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.tabs button {
padding: 0.5rem 1rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button[aria-selected='true'] {
color: var(--color-text);
border-bottom-color: var(--color-primary);
}
.bar {
display: flex;
justify-content: flex-end;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.875rem;
}
.user-cell {
display: flex;
flex-direction: column;
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
}
.form input,
.form select {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.status {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.status--error {
color: var(--color-danger-text);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { decideDevice, lookupDeviceCode, type DeviceInfo } from '$lib/api/endpoints/device';
import { t } from '$lib/i18n/index.svelte';
type Step = 'code' | 'loading' | 'review' | 'approved' | 'denied' | 'error';
let code = $state(page.url.searchParams.get('code') ?? '');
let step = $state<Step>('code');
let info = $state<DeviceInfo | null>(null);
let errorText = $state('');
let busy = $state(false);
async function lookup(e?: SubmitEvent) {
e?.preventDefault();
if (!code) return;
step = 'loading';
errorText = '';
try {
info = await lookupDeviceCode(code);
step = 'review';
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
step = 'error';
}
}
async function decide(action: 'approve' | 'deny') {
busy = true;
try {
await decideDevice(code, action);
step = action === 'approve' ? 'approved' : 'denied';
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
step = 'error';
} finally {
busy = false;
}
}
onMount(() => {
if (code) void lookup();
});
</script>
<svelte:head><title>{t('device.title', 'Device verification')} · OxiCloud</title></svelte:head>
<main class="device">
<div class="device__card">
<h1>{t('device.title', 'Device verification')}</h1>
{#if step === 'code'}
<form onsubmit={lookup}>
<label class="device__field">
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
<input bind:value={code} autocomplete="off" inputmode="text" />
</label>
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
</form>
{:else if step === 'loading'}
<p>{t('common.loading', 'Loading…')}</p>
{:else if step === 'review'}
<dl class="device__info">
<dt>{t('device.client', 'Application')}</dt>
<dd>{info?.client_name || t('device.unknown', 'Unknown')}</dd>
<dt>{t('device.scopes', 'Access')}</dt>
<dd>{info?.scopes || 'all'}</dd>
</dl>
<div class="device__actions">
<button class="device__deny" disabled={busy} onclick={() => decide('deny')}>
{t('device.deny', 'Deny')}
</button>
<button class="device__approve" disabled={busy} onclick={() => decide('approve')}>
{t('device.approve', 'Approve')}
</button>
</div>
{:else if step === 'approved'}
<p class="device__ok">
{t('device.approved', 'Device approved. You can return to your device.')}
</p>
{:else if step === 'denied'}
<p>{t('device.denied', 'Device access denied.')}</p>
{:else if step === 'error'}
<p class="device__error" role="alert">{errorText}</p>
<button onclick={() => (step = 'code')}>{t('common.retry', 'Try again')}</button>
{/if}
</div>
</main>
<style>
.device {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
background: var(--color-bg-page);
}
.device__card {
width: min(92vw, 24rem);
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.device__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.device__field input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: 1.25rem;
letter-spacing: 0.1em;
text-align: center;
}
.device__info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0;
}
.device__info dt {
color: var(--color-text-muted);
}
.device__info dd {
margin: 0;
color: var(--color-text);
}
.device__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.device__approve {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.device__deny {
color: var(--color-danger-text);
}
.device__ok {
color: var(--color-success-text);
}
.device__error {
color: var(--color-danger-text);
}
</style>
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
fetchFavoritesPage,
removeFavorite,
type FavoritesResourceItem
} from '$lib/api/endpoints/favorites';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<FavoritesResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchFavoritesPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function unfavorite(item: FavoritesResourceItem) {
try {
await removeFavorite(item.resource_type, item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.favorites', 'Favorites')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.favorites', 'Favorites')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('favorites.empty', 'No favorites yet.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.favorited_at)}
>
{#snippet actions()}
<button class="link-btn" onclick={() => unfavorite(item)}>
{t('favorites.remove', 'Remove')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
@@ -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>
+306
View File
@@ -0,0 +1,306 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addUserMember,
createGroup,
deleteGroup,
listGroups,
listMembers,
removeGroupMember,
removeUserMember,
renameGroup,
type GroupItem,
type GroupMember
} from '$lib/api/endpoints/groups';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
let groups = $state<GroupItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let expandedId = $state<string | null>(null);
let members = $state<GroupMember[]>([]);
async function load() {
loading = true;
error = null;
try {
groups = await listGroups();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function report(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
async function expand(g: GroupItem) {
if (expandedId === g.id) {
expandedId = null;
return;
}
expandedId = g.id;
try {
members = await listMembers(g.id);
} catch (e) {
report(e);
members = [];
}
}
async function onCreate() {
const name = prompt(t('groups.new_prompt', 'New group name'));
if (!name) return;
try {
await createGroup(name);
await load();
} catch (e) {
report(e);
}
}
async function onRename(g: GroupItem) {
const name = prompt(t('groups.rename_prompt', 'New name'), g.name);
if (!name || name === g.name) return;
try {
await renameGroup(g.id, name);
await load();
} catch (e) {
report(e);
}
}
async function onDelete(g: GroupItem) {
if (!confirm(t('groups.confirm_delete', { name: g.name }, 'Delete group "{{name}}"?'))) return;
try {
await deleteGroup(g.id);
if (expandedId === g.id) expandedId = null;
await load();
} catch (e) {
report(e);
}
}
async function onAddMember(g: GroupItem) {
const userId = prompt(t('groups.add_member_prompt', 'User ID to add'));
if (!userId) return;
try {
await addUserMember(g.id, userId);
members = await listMembers(g.id);
} catch (e) {
report(e);
}
}
async function onRemoveMember(groupId: string, m: GroupMember) {
try {
if (m.user_id) await removeUserMember(groupId, m.user_id);
else if (m.group_id) await removeGroupMember(groupId, m.group_id);
members = await listMembers(groupId);
} catch (e) {
report(e);
}
}
onMount(load);
</script>
<svelte:head><title>{t('nav.groups', 'Groups')} · OxiCloud</title></svelte:head>
<main class="groups">
<header class="groups__head">
<h1>{t('nav.groups', 'Groups')}</h1>
<button class="btn btn--primary" onclick={onCreate}>{t('groups.create', 'Create group')}</button
>
</header>
{#if error}
<p class="status status--error">{error}</p>
{:else if loading}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else if groups.length === 0}
<p class="status">{t('groups.empty', 'No groups yet.')}</p>
{:else}
<ul class="list">
{#each groups as g (g.id)}
<li class="group">
<div class="group__row">
<button class="group__name" onclick={() => expand(g)}>
{g.name}
{#if g.member_count != null}<span class="muted">({g.member_count})</span>{/if}
</button>
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
</div>
{#if expandedId === g.id}
<div class="members">
<div class="members__head">
<h2>{t('groups.members', 'Members')}</h2>
<button class="link-btn" onclick={() => onAddMember(g)}>
{t('groups.add_member', 'Add member')}
</button>
</div>
{#if members.length === 0}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{:else}
<ul class="members__list">
{#each members as m (m.user_id ?? m.group_id)}
<li>
<span>{m.email ?? m.name ?? m.user_id ?? m.group_id}</span>
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</main>
<style>
.groups {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.groups__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.groups__head h1 {
margin: 0;
font-size: 1.5rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
}
.group__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
}
.group__name {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
}
.group__actions {
display: flex;
gap: 0.5rem;
}
.members {
border-top: 1px solid var(--color-border);
padding: 0.75rem;
}
.members__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.members__head h2 {
margin: 0;
font-size: 1rem;
}
.members__list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.members__list li {
display: flex;
align-items: center;
justify-content: space-between;
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.status {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.status--error {
color: var(--color-danger-text);
}
.btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { login } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
let username = $state('');
let password = $state('');
let error = $state('');
let busy = $state(false);
const redirectTarget = $derived(page.url.searchParams.get('redirect') || '/files');
function csrfCookiePresent(): boolean {
return document.cookie.split('; ').some((c) => c.startsWith('oxicloud_csrf='));
}
async function onsubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
const data = await login(username, password);
// Tokens are HttpOnly cookies set by the server. Verify the browser
// actually accepted them: the non-HttpOnly CSRF cookie must be present.
if (!csrfCookiePresent()) {
error = t(
'auth.cookie_rejected',
'Login succeeded but the browser rejected the session cookie. If you are on HTTP, set OXICLOUD_COOKIE_SECURE=false or use HTTPS.'
);
return;
}
session.user = data.user;
await goto(redirectTarget, { replaceState: true });
} catch (err) {
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>{t('app.title', 'OxiCloud')}</title>
</svelte:head>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h1 class="auth-title">{t('auth.sign_in', 'Sign in')}</h1>
{#if page.url.searchParams.get('source') === 'session_expired'}
<div class="auth-error" style="display: block">
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
</div>
{/if}
{#if error}
<div class="auth-error" style="display: block" role="alert">{error}</div>
{/if}
<form class="auth-form" {onsubmit} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
</label>
<input
id="login-username"
class="auth-input"
type="text"
bind:value={username}
autocomplete="username"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label>
<input
id="login-password"
class="auth-input"
type="password"
bind:value={password}
autocomplete="current-password"
required
disabled={busy}
/>
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
</div>
</div>
+360
View File
@@ -0,0 +1,360 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import {
createPlaylist,
deletePlaylist,
listPlaylists,
listTracks,
removeTrack,
reorderTracks,
type Playlist,
type PlaylistItem
} from '$lib/api/endpoints/music';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
let playlists = $state<Playlist[]>([]);
let current = $state<Playlist | null>(null);
let tracks = $state<PlaylistItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let nowPlaying = $state<string | null>(null);
// native HTML5 drag-reorder state
let dragIndex = $state<number | null>(null);
async function loadPlaylists() {
loading = true;
error = null;
try {
playlists = await listPlaylists();
if (!current && playlists.length > 0) await select(playlists[0]);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function select(p: Playlist) {
current = p;
try {
tracks = await listTracks(p.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onCreate() {
const name = prompt(t('music.new_playlist', 'New playlist name'));
if (!name) return;
try {
const p = await createPlaylist(name);
playlists = [...playlists, p];
await select(p);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onDelete(p: Playlist) {
if (!confirm(t('music.confirm_delete', { name: p.name }, 'Delete playlist "{{name}}"?')))
return;
try {
await deletePlaylist(p.id);
playlists = playlists.filter((x) => x.id !== p.id);
if (current?.id === p.id) {
current = playlists[0] ?? null;
tracks = current ? await listTracks(current.id) : [];
}
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onRemoveTrack(track: PlaylistItem) {
if (!current) return;
try {
await removeTrack(current.id, track.file_id);
tracks = tracks.filter((x) => x.id !== track.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function onDragStart(i: number) {
dragIndex = i;
}
function onDragOver(e: DragEvent, i: number) {
e.preventDefault();
if (dragIndex === null || dragIndex === i) return;
const next = [...tracks];
const [moved] = next.splice(dragIndex, 1);
next.splice(i, 0, moved);
dragIndex = i;
tracks = next;
}
async function onDrop() {
dragIndex = null;
if (!current) return;
try {
await reorderTracks(
current.id,
tracks.map((tr) => tr.id)
);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
await select(current); // reload server order on failure
}
}
function trackLabel(tr: PlaylistItem): string {
return tr.title || tr.file_name || tr.file_id;
}
onMount(loadPlaylists);
</script>
<svelte:head><title>{t('nav.music', 'Music')} · OxiCloud</title></svelte:head>
<div class="music">
<aside class="music__sidebar">
<div class="music__sidebar-head">
<h1>{t('nav.music', 'Music')}</h1>
<button class="link-btn" onclick={onCreate}>+ {t('music.new', 'New')}</button>
</div>
{#if error}
<p class="status status--error">{error}</p>
{:else if loading && playlists.length === 0}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else if playlists.length === 0}
<p class="status">{t('music.no_playlists', 'No playlists yet.')}</p>
{:else}
<ul class="playlists">
{#each playlists as p (p.id)}
<li>
<button
class="playlists__item"
class:playlists__item--active={current?.id === p.id}
onclick={() => select(p)}
>
<Icon name="music" class="playlists__icon" />
<span class="playlists__name">{p.name}</span>
<span class="playlists__count">{p.track_count}</span>
</button>
<button
class="link-btn link-btn--danger"
aria-label={t('common.delete', 'Delete')}
onclick={() => onDelete(p)}>×</button
>
</li>
{/each}
</ul>
{/if}
</aside>
<section class="music__main">
{#if current}
<header class="music__main-head">
<h2>{current.name}</h2>
</header>
{#if tracks.length === 0}
<p class="status">{t('music.empty_playlist', 'This playlist has no tracks yet.')}</p>
{:else}
<ul class="tracks">
{#each tracks as track, i (track.id)}
<li
class="track"
draggable="true"
ondragstart={() => onDragStart(i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={onDrop}
ondragend={() => (dragIndex = null)}
>
<span class="track__grip" aria-hidden="true"><Icon name="bars" /></span>
<button class="track__play" onclick={() => (nowPlaying = track.file_id)}>
<Icon name={nowPlaying === track.file_id ? 'pause' : 'play'} />
</button>
<span class="track__title">{trackLabel(track)}</span>
{#if track.artist}<span class="track__artist">{track.artist}</span>{/if}
<button class="link-btn link-btn--danger" onclick={() => onRemoveTrack(track)}>
{t('common.remove', 'Remove')}
</button>
</li>
{/each}
</ul>
{/if}
{#if nowPlaying}
<audio class="music__player" src={fileInlineUrl(nowPlaying)} controls autoplay></audio>
{/if}
{:else}
<p class="status">{t('music.select_playlist', 'Select a playlist.')}</p>
{/if}
</section>
</div>
<style>
.music {
display: grid;
grid-template-columns: 16rem 1fr;
min-height: 100vh;
}
.music__sidebar {
border-right: 1px solid var(--color-border);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.music__sidebar-head,
.music__main-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.music__sidebar-head h1 {
font-size: 1.25rem;
margin: 0;
}
.playlists {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.playlists li {
display: flex;
align-items: center;
}
.playlists__item {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.5rem;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
text-align: left;
}
.playlists__item:hover {
background: var(--color-bg-hover);
}
.playlists__item--active {
background: var(--color-bg-hover);
font-weight: 600;
}
.playlists__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlists__count {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.music__main {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tracks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.track {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
}
.track__grip {
cursor: grab;
color: var(--color-text-muted);
}
.track__play {
border: none;
background: none;
cursor: pointer;
color: var(--color-primary);
}
.track__title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track__artist {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.music__player {
position: sticky;
bottom: 0;
width: 100%;
}
.status {
color: var(--color-text-muted);
padding: 1rem 0;
}
.status--error {
color: var(--color-danger-text);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
@media (width <= 48rem) {
.music {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts">
import { page } from '$app/state';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
const reason = $derived(page.url.searchParams.get('reason') ?? '');
</script>
<svelte:head
><title>{t('nextcloud.error_title', 'Something went wrong')} · OxiCloud</title></svelte:head
>
<main class="nc-status">
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
<h1>{t('nextcloud.error_title', 'Something went wrong')}</h1>
<p>
{t(
'nextcloud.error_body',
'The connection could not be completed. Please try again from your application.'
)}
</p>
{#if reason}<p class="nc-status__reason">{reason}</p>{/if}
</main>
<style>
.nc-status {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
padding: 2rem 1rem;
}
:global(.nc-status__icon) {
font-size: 3rem;
}
:global(.nc-status__icon--err) {
color: var(--color-danger-text);
}
.nc-status__reason {
color: var(--color-text-muted);
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client';
import { t } from '$lib/i18n/index.svelte';
// Nextcloud Login Flow v2. The form does a NATIVE POST to the backend flow
// endpoint so the server drives the redirect handshake — do not intercept it.
// The flow token is hex; reject anything else to prevent action injection.
const token = $derived(page.url.searchParams.get('token') ?? '');
const validToken = $derived(/^[0-9a-fA-F]+$/.test(token));
const formAction = $derived(`/login/v2/flow/${token}`);
let oidcEnabled = $state(false);
let oidcProvider = $state('SSO');
let passwordLoginEnabled = $state(true);
onMount(async () => {
try {
const resp = await apiFetch('/api/auth/oidc/providers');
if (!resp.ok) return;
const info = (await resp.json()) as {
enabled?: boolean;
provider_name?: string;
password_login_enabled?: boolean;
};
if (!info.enabled) return;
oidcEnabled = true;
oidcProvider = info.provider_name || 'SSO';
passwordLoginEnabled = info.password_login_enabled !== false;
} catch {
/* OIDC not available — password-only */
}
});
</script>
<svelte:head><title>{t('app.title', 'OxiCloud')}</title></svelte:head>
<main class="nc">
<div class="nc__card">
<h1>{t('nextcloud.grant_title', 'Grant access')}</h1>
{#if !validToken}
<p class="nc__error">{t('nextcloud.invalid_token', 'Invalid session token.')}</p>
{:else}
{#if passwordLoginEnabled}
<form method="post" action={formAction} class="nc__form">
<label>
<span>{t('auth.username', 'Username or email')}</span>
<input name="user" type="text" autocomplete="username" required />
</label>
<label>
<span>{t('auth.password', 'Password')}</span>
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">{t('nextcloud.grant', 'Grant access')}</button>
</form>
{/if}
{#if oidcEnabled}
<div class="nc__oidc">
<a class="nc__sso" href={`/login/v2/flow/${token}/oidc`}>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
</div>
{/if}
{/if}
</div>
</main>
<style>
.nc {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
background: var(--color-bg-page);
}
.nc__card {
width: min(92vw, 22rem);
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.nc__form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
input {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
button,
.nc__sso {
display: inline-block;
text-align: center;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
text-decoration: none;
cursor: pointer;
}
.nc__error {
color: var(--color-danger-text);
}
</style>
@@ -0,0 +1,34 @@
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
</script>
<svelte:head><title>{t('nextcloud.success_title', 'Access granted')} · OxiCloud</title></svelte:head
>
<main class="nc-status">
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
</main>
<style>
.nc-status {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
padding: 2rem 1rem;
}
:global(.nc-status__icon) {
font-size: 3rem;
}
:global(.nc-status__icon--ok) {
color: var(--color-success-text);
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchPhotos } from '$lib/api/endpoints/photos';
import { fileInlineUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
let items = $state<FileItem[]>([]);
let cursor = $state<string | null>(null);
let exhausted = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
let sentinel = $state<HTMLElement | null>(null);
async function loadMore() {
if (loading || exhausted) return;
loading = true;
error = null;
try {
const page = await fetchPhotos(60, cursor);
items = [...items, ...page.items];
cursor = page.nextCursor;
if (!page.nextCursor) exhausted = true;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
exhausted = true;
} finally {
loading = false;
}
}
onMount(() => {
void loadMore();
if (!sentinel) return;
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: '600px' }
);
obs.observe(sentinel);
return () => obs.disconnect();
});
</script>
<svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
{#if error}
<p class="status status--error" role="alert">{error}</p>
{:else if items.length === 0 && exhausted}
<p class="status">{t('photos.empty', 'No photos yet.')}</p>
{:else}
<ul class="photos">
{#each items as photo (photo.id)}
<li class="photos__cell">
<a href={fileInlineUrl(photo.id)} target="_blank" rel="noreferrer">
<img src={fileThumbnailUrl(photo.id)} alt={photo.name} loading="lazy" decoding="async" />
</a>
</li>
{/each}
</ul>
{/if}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.photos {
list-style: none;
margin: 0;
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem;
}
.photos__cell {
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.photos__cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.status {
text-align: center;
color: var(--color-text-muted);
padding: 2rem 0;
}
.status--error {
color: var(--color-danger-text);
}
.sentinel {
height: 1px;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { changePassword, updateProfile } from '$lib/api/endpoints/profile';
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
let givenName = $state('');
let familyName = $state('');
let username = $state('');
let preferredLocale = $state<string>('');
let notifyOnShare = $state(true);
let currentPw = $state('');
let newPw = $state('');
let confirmPw = $state('');
let savingProfile = $state(false);
let savingPassword = $state(false);
function hydrate() {
const u = session.user;
if (!u) return;
givenName = u.given_name ?? '';
familyName = u.family_name ?? '';
username = u.username ?? '';
preferredLocale = u.preferred_locale ?? '';
notifyOnShare = u.notify_on_share;
}
async function saveProfile(e: SubmitEvent) {
e.preventDefault();
savingProfile = true;
try {
const updated = await updateProfile({
given_name: givenName,
family_name: familyName,
username: username || undefined,
preferred_locale: preferredLocale || undefined,
notify_on_share: notifyOnShare
});
session.user = updated;
if (preferredLocale) await setLocale(preferredLocale as Locale);
ui.notify(t('profile.saved', 'Profile saved'), 'success');
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
savingProfile = false;
}
}
async function savePassword(e: SubmitEvent) {
e.preventDefault();
if (newPw !== confirmPw) {
ui.notify(t('profile.password_mismatch', 'Passwords do not match'), 'error');
return;
}
savingPassword = true;
try {
await changePassword(currentPw, newPw);
currentPw = newPw = confirmPw = '';
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
savingPassword = false;
}
}
onMount(async () => {
if (!session.loaded) await session.load();
hydrate();
});
</script>
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head>
<main class="profile">
<h1>{t('nav.profile', 'Profile')}</h1>
{#if session.user}
<p class="profile__email">{session.user.email}</p>
<form class="card" onsubmit={saveProfile}>
<h2>{t('profile.details', 'Profile details')}</h2>
<label>
<span>{t('profile.username', 'Username')}</span>
<input bind:value={username} autocomplete="username" />
</label>
<label>
<span>{t('profile.given_name', 'Given name')}</span>
<input bind:value={givenName} autocomplete="given-name" />
</label>
<label>
<span>{t('profile.family_name', 'Family name')}</span>
<input bind:value={familyName} autocomplete="family-name" />
</label>
<label>
<span>{t('profile.language', 'Language')}</span>
<select bind:value={preferredLocale}>
<option value="">{t('profile.language_auto', 'Automatic')}</option>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
{/each}
</select>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={notifyOnShare} />
<span>{t('profile.notify_on_share', 'Email me when something is shared with me')}</span>
</label>
<button type="submit" disabled={savingProfile}>{t('common.save', 'Save')}</button>
</form>
{#if session.user.can_edit_image !== false && session.user.auth_provider === 'local'}
<form class="card" onsubmit={savePassword}>
<h2>{t('profile.change_password', 'Change password')}</h2>
<label>
<span>{t('profile.current_password', 'Current password')}</span>
<input type="password" bind:value={currentPw} autocomplete="current-password" />
</label>
<label>
<span>{t('profile.new_password', 'New password')}</span>
<input type="password" bind:value={newPw} autocomplete="new-password" />
</label>
<label>
<span>{t('profile.confirm_password', 'Confirm new password')}</span>
<input type="password" bind:value={confirmPw} autocomplete="new-password" />
</label>
<button type="submit" disabled={savingPassword}>
{t('profile.update_password', 'Update password')}
</button>
</form>
{/if}
{:else}
<p>{t('common.loading', 'Loading…')}</p>
{/if}
</main>
<style>
.profile {
max-width: 36rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.profile__email {
margin: -1rem 0 0;
color: var(--color-text-muted);
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 1.125rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--color-text);
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
input,
select {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: 1rem;
}
label.checkbox input {
width: auto;
}
button {
align-self: flex-start;
padding: 0.5rem 1.25rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
button:disabled {
opacity: 0.6;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<RecentResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchRecentPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function clearAll() {
try {
await clearRecent();
items = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.recent', 'Recent')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.recent', 'Recent')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('recent.empty', 'No recent items.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn" onclick={clearAll}>{t('recent.clear', 'Clear')}</button>
{/if}
{/snippet}
{#each items as item (item.resource.id + item.accessed_at)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.accessed_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
+294
View File
@@ -0,0 +1,294 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import {
getShareContents,
getShareMeta,
shareDownloadUrl,
shareFileUrl,
shareZipUrl,
verifySharePassword,
type ShareListing,
type ShareMeta
} from '$lib/api/endpoints/share';
import { t } from '$lib/i18n/index.svelte';
type State = 'loading' | 'password' | 'expired' | 'file' | 'folder';
const token = $derived(page.params.token ?? '');
let view = $state<State>('loading');
let meta = $state<ShareMeta | null>(null);
let listing = $state<ShareListing | null>(null);
let folderId = $state<string | undefined>(undefined);
let folderName = $state<string>('');
let pwInput = $state('');
let pwError = $state('');
let busy = $state(false);
async function loadMeta() {
view = 'loading';
try {
const r = await getShareMeta(token);
if (r.status === 'password') {
view = 'password';
} else if (r.status === 'expired') {
view = 'expired';
} else {
meta = r.data;
if (r.data.item_type === 'folder') await openFolder(undefined, r.data.item_name);
else view = 'file';
}
} catch {
view = 'expired';
}
}
async function openFolder(id: string | undefined, name: string) {
const r = await getShareContents(token, id);
if (r.status === 'password') {
view = 'password';
return;
}
if (r.status === 'expired') {
view = 'expired';
return;
}
listing = r.data;
folderId = id;
folderName = name;
view = 'folder';
}
async function submitPassword(e: SubmitEvent) {
e.preventDefault();
if (!pwInput) return;
busy = true;
pwError = '';
try {
const ok = await verifySharePassword(token, pwInput);
if (!ok) {
pwError = t('share.bad_password', 'Incorrect password. Please try again.');
return;
}
await loadMeta();
} catch {
pwError = t('share.error', 'Something went wrong. Please try again.');
} finally {
busy = false;
}
}
onMount(loadMeta);
</script>
<svelte:head><title>{meta?.item_name ?? t('share.title', 'Shared')} · OxiCloud</title></svelte:head>
<main class="share">
{#if view === 'loading'}
<p class="share__status">{t('common.loading', 'Loading…')}</p>
{:else if view === 'expired'}
<div class="share__center">
<Icon name="ban" class="share__big-icon" />
<p>{t('share.expired', 'This share link is no longer available.')}</p>
</div>
{:else if view === 'password'}
<form class="share__pw" onsubmit={submitPassword}>
<h1>{t('share.password_title', 'Password required')}</h1>
<input
type="password"
bind:value={pwInput}
placeholder={t('share.password', 'Password')}
disabled={busy}
autocomplete="off"
/>
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
<button type="submit" disabled={busy}>{t('share.unlock', 'Unlock')}</button>
</form>
{:else if view === 'file'}
<div class="share__center">
<Icon name="file" class="share__big-icon" />
<h1>{meta?.item_name}</h1>
<a class="share__btn" href={shareDownloadUrl(token)} download>
{t('share.download', 'Download')}
</a>
</div>
{:else if view === 'folder' && listing}
<header class="share__header">
<h1>{folderName}</h1>
<div class="share__header-actions">
{#if folderId}
<button class="link-btn" onclick={() => openFolder(undefined, meta?.item_name ?? '')}>
← {t('share.back_to_root', 'Back to share root')}
</button>
{/if}
<a class="share__btn" href={shareZipUrl(token, folderId)} download>
{t('share.download_zip', 'Download ZIP')}
</a>
</div>
</header>
{#if listing.folders.length === 0 && listing.files.length === 0}
<p class="share__status">{t('share.empty_folder', 'This folder is empty.')}</p>
{/if}
{#if listing.folders.length > 0}
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
<ul class="share__grid">
{#each listing.folders as f (f.id)}
<li>
<button class="card" onclick={() => openFolder(f.id, f.name)}>
<Icon name="folder" class="card__icon" />
<span class="card__name">{f.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if listing.files.length > 0}
<h2 class="share__section">{t('share.files', 'Files')}</h2>
<ul class="share__grid">
{#each listing.files as f (f.id)}
<li>
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
<Icon name="file" class="card__icon" />
<span class="card__name">{f.name}</span>
</a>
</li>
{/each}
</ul>
{/if}
{/if}
</main>
<style>
.share {
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.share__center {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 0;
text-align: center;
}
:global(.share__big-icon) {
font-size: 3rem;
color: var(--color-text-muted);
}
.share__status {
text-align: center;
color: var(--color-text-muted);
padding: 3rem 0;
}
.share__pw {
max-width: 22rem;
margin: 4rem auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.share__pw input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.share__error {
color: var(--color-danger-text);
font-size: 0.875rem;
margin: 0;
}
.share__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.share__header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.share__section {
font-size: 1rem;
color: var(--color-text-muted);
margin: 1.5rem 0 0.5rem;
}
.share__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 1rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
text-decoration: none;
}
.card:hover {
background: var(--color-bg-hover);
}
:global(.card__icon) {
font-size: 2rem;
color: var(--color-text-muted);
}
.card__name {
font-size: 0.8125rem;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.share__btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
text-decoration: none;
cursor: pointer;
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<IncomingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchSharedWithMe({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared_with_me', 'Shared with me')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.shared_with_me', 'Shared with me')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.granted_by
? t('shared_with_me.from', { who: item.granted_by }, 'Shared by {{who}}')
: item.resource.path}
date={formatDate(item.granted_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchMyShares, type OutgoingGrantItem } from '$lib/api/endpoints/grants';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<OutgoingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchMyShares({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared', 'Shared')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared.empty', "You haven't shared anything yet.")}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.subject
? t('shared.with', { who: item.subject }, 'Shared with {{who}}')
: item.resource.path}
date={formatDate(item.first_shared_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
deleteTrashItem,
emptyTrash,
fetchTrashPage,
restoreTrashItem
} from '$lib/api/endpoints/trash';
import type { TrashResourceItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<TrashResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchTrashPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function restore(item: TrashResourceItem) {
try {
await restoreTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
ui.notify(t('trash.restored', 'Restored'), 'success');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purge(item: TrashResourceItem) {
if (!confirm(t('trash.confirm_delete', 'Permanently delete this item?'))) return;
try {
await deleteTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purgeAll() {
if (!confirm(t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'))) return;
try {
await emptyTrash();
items = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.trash', 'Trash')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.trash', 'Trash')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('trash.empty', 'Trash is empty.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn link-btn--danger" onclick={purgeAll}>
{t('trash.empty_action', 'Empty trash')}
</button>
{/if}
{/snippet}
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.deletion_date)}
>
{#snippet actions()}
<button class="link-btn" onclick={() => restore(item)}>
{t('trash.restore', 'Restore')}
</button>
<button class="link-btn link-btn--danger" onclick={() => purge(item)}>
{t('trash.delete', 'Delete')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>