init new frontend
This commit is contained in:
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user