diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
index a260514..6e03849 100644
--- a/PROJECT_CONTEXT.md
+++ b/PROJECT_CONTEXT.md
@@ -1,6 +1,6 @@
# BoxBox Project Context
-> **Last updated:** 2026-05-06
+> **Last updated:** 2026-06-12
> **Purpose:** Single source of truth for the non-`docs/` Markdown files and current codebase state.
> **Scope:** Consolidates project truth from the previous non-`docs/` Markdown set. The old audit, roadmap, refactor-plan, and nested starter README files were removed on 2026-05-06 after this file became the local source of truth.
> **Excluded by request:** Contents of `docs/` Markdown files are not merged here, though this file notes known drift between `docs/` and the implementation where it affects project truth.
@@ -321,7 +321,7 @@ Backend testing gaps remain:
- SvelteKit app is built as a static SPA with fallback.
- Root layout initializes Svelte Query and handles auth redirects.
-- Main app flow includes login, browse, settings, and a manual `/test` component/demo route.
+- Main app flow includes login, browse, and settings. The old unauthenticated `/test` component/demo route was removed during the 2026-06-12 cleanup.
- `browse/+page.svelte` is the main file manager screen and composes the active UI.
### Important frontend directories
@@ -347,7 +347,7 @@ Older frontend refactor files are stale in several areas. These are now implemen
- `src/lib/utils/fileTypes.ts` exists and is the central file-type source.
- `src/lib/utils/format.ts` exists for formatting.
- Design tokens are defined in `src/routes/layout.css`.
-- `src/lib/components/ui/` exists with base components including `Button`, `Input`, `Select`, `Toggle`, `Card`, `Modal`, `Spinner`, `Badge`, `ProgressBar`, `ContextMenu`, `Toast`, and `InlineRename`.
+- `src/lib/components/ui/` exists with base components including `Button`, `Input`, `Select`, `Toggle`, `Modal`, `Spinner`, `Badge`, `ProgressBar`, `ContextMenu`, `Toast`, and `InlineRename`.
- No `
diff --git a/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte b/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte
index 409854c..0ec4dea 100644
--- a/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte
+++ b/frontend/src/lib/components/settings/wallpaper/WallpaperSettings.svelte
@@ -1,7 +1,7 @@
-
-{#if isButton}
-
- {@render children()}
-
-{:else}
-
- {@render children()}
-
-{/if}
diff --git a/frontend/src/lib/components/ui/ContextMenu.svelte b/frontend/src/lib/components/ui/ContextMenu.svelte
index 8a4ba1d..a2207ea 100644
--- a/frontend/src/lib/components/ui/ContextMenu.svelte
+++ b/frontend/src/lib/components/ui/ContextMenu.svelte
@@ -3,8 +3,6 @@
* Context Menu component - reusable right-click menu
* Follows UI component patterns from contributing guidelines
*/
- import type { Snippet } from 'svelte';
-
export interface ContextMenuItem {
id: string;
label: string;
@@ -29,22 +27,22 @@
// Adjust position to keep menu within viewport
let adjustedPosition = $derived.by(() => {
if (!menuRef) return { x, y };
-
+
const menuWidth = 200; // approximate width
const menuHeight = items.length * 36; // approximate height
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1920;
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 1080;
-
+
let adjustedX = x;
let adjustedY = y;
-
+
if (x + menuWidth > viewportWidth) {
adjustedX = viewportWidth - menuWidth - 8;
}
if (y + menuHeight > viewportHeight) {
adjustedY = viewportHeight - menuHeight - 8;
}
-
+
return { x: adjustedX, y: adjustedY };
});
@@ -74,39 +72,42 @@
{ e.preventDefault(); onClose(); }}
+ oncontextmenu={(e) => {
+ e.preventDefault();
+ onClose();
+ }}
>
{#each items as item (item.id)}
{#if item.separator}
-
+
{:else}
handleItemClick(item)}
role="menuitem"
>
{#if item.icon}
- {@const IconComponent = item.icon}
-
-
+ {@const IconComponent = item.icon}
+
+
{:else}
{/if}
{item.label}
{#if item.shortcut}
- {item.shortcut}
+ {item.shortcut}
{/if}
{/if}
diff --git a/frontend/src/lib/components/ui/ProgressButton.svelte b/frontend/src/lib/components/ui/ProgressButton.svelte
new file mode 100644
index 0000000..c749f11
--- /dev/null
+++ b/frontend/src/lib/components/ui/ProgressButton.svelte
@@ -0,0 +1,108 @@
+
+
+
+ {#if showProgress}
+
+ {/if}
+
+ {@render children()}
+
+ {#if progressLabel}
+ {progressLabel}
+ {/if}
+
diff --git a/frontend/src/lib/components/ui/Select.svelte b/frontend/src/lib/components/ui/Select.svelte
index 2d87c6b..646ca70 100644
--- a/frontend/src/lib/components/ui/Select.svelte
+++ b/frontend/src/lib/components/ui/Select.svelte
@@ -1,4 +1,6 @@
-
- {#each options as option (option.value)}
- {option.label}
- {/each}
-
+
+
+ {#each options as option (option.value)}
+ {option.label}
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/ui/Toast.svelte b/frontend/src/lib/components/ui/Toast.svelte
index fb6bc29..e2d5da4 100644
--- a/frontend/src/lib/components/ui/Toast.svelte
+++ b/frontend/src/lib/components/ui/Toast.svelte
@@ -3,7 +3,7 @@
* Toast notification container component
* Displays toast notifications from the toastStore
*/
- import { toastStore, type Toast } from '$lib/stores/toast.svelte';
+ import { toastStore } from '$lib/stores/toast.svelte';
import { CheckCircle, XCircle, Info, AlertTriangle, X } from 'lucide-svelte';
import { fly, fade } from 'svelte/transition';
@@ -34,28 +34,31 @@
{#if toastStore.toasts.length > 0}
-
+
{#each toastStore.toasts as toast (toast.id)}
+ {@const Icon = iconMap[toast.type]}
-
-
+
+
-
+
{toast.message}
handleDismiss(toast.id)}
aria-label="Dismiss notification"
>
diff --git a/frontend/src/lib/components/ui/index.ts b/frontend/src/lib/components/ui/index.ts
index b240c3e..365d5e6 100644
--- a/frontend/src/lib/components/ui/index.ts
+++ b/frontend/src/lib/components/ui/index.ts
@@ -4,10 +4,10 @@
*/
export { default as Button } from './Button.svelte';
+export { default as ProgressButton } from './ProgressButton.svelte';
export { default as Input } from './Input.svelte';
export { default as Select } from './Select.svelte';
export { default as Toggle } from './Toggle.svelte';
-export { default as Card } from './Card.svelte';
export { default as Modal } from './Modal.svelte';
export { default as Spinner } from './Spinner.svelte';
export { default as Badge } from './Badge.svelte';
diff --git a/frontend/src/lib/stores/files.ts b/frontend/src/lib/stores/files.ts
index ae65ccb..aad7e55 100644
--- a/frontend/src/lib/stores/files.ts
+++ b/frontend/src/lib/stores/files.ts
@@ -4,15 +4,8 @@
*/
import { writable, derived, get } from 'svelte/store';
-import {
- listRoots,
- listDirectory,
- createDirectory,
- rename,
- deleteFile,
- search,
- type ListOptions
-} from '$lib/api/files';
+import { type ListOptions } from '$lib/api/files';
+import { CONFIG } from '$lib/config';
/**
* Current path state
@@ -114,7 +107,7 @@ export interface ListOptionsState extends ListOptions {
*/
const defaultListOptions: ListOptionsState = {
page: 1,
- pageSize: 50,
+ pageSize: CONFIG.ui.defaultPageSize,
sortBy: 'name',
sortDir: 'asc',
filter: ''
@@ -190,179 +183,3 @@ export const fileQueryKeys = {
[...fileQueryKeys.all, 'list', path, options] as const,
search: (path: string, query: string) => [...fileQueryKeys.all, 'search', path, query] as const
};
-
-/**
- * Query options factory for listing mount points (roots)
- */
-export function rootsQueryOptions() {
- return {
- queryKey: fileQueryKeys.roots(),
- queryFn: () => listRoots()
- };
-}
-
-/**
- * Query options factory for listing directory contents
- */
-export function directoryQueryOptions(path: string, options: ListOptions) {
- return {
- queryKey: fileQueryKeys.list(path, options),
- queryFn: () => listDirectory(path, options),
- enabled: path !== ''
- };
-}
-
-/**
- * Query options factory for searching files
- */
-export function searchQueryOptions(path: string, query: string) {
- return {
- queryKey: fileQueryKeys.search(path, query),
- queryFn: () => search(path, query),
- enabled: query.length > 0
- };
-}
-
-/**
- * Mutation options for creating directories
- */
-export function createDirectoryMutationOptions() {
- return {
- mutationFn: ({ basePath, name }: { basePath: string; name: string }) =>
- createDirectory(basePath, name)
- };
-}
-
-/**
- * Mutation options for renaming files/directories
- */
-export function renameMutationOptions() {
- return {
- mutationFn: ({ oldPath, newPath }: { oldPath: string; newPath: string }) =>
- rename(oldPath, newPath)
- };
-}
-
-/**
- * Mutation options for deleting files/directories
- */
-export function deleteMutationOptions() {
- return {
- mutationFn: ({ path, confirm }: { path: string; confirm?: boolean }) =>
- deleteFile(path, confirm)
- };
-}
-
-/**
- * Selection state for multi-select operations
- */
-export interface SelectionState {
- selectedItems: Set;
- lastSelectedItem: string | null;
-}
-
-/**
- * Create the selection store
- */
-function createSelectionStore() {
- const { subscribe, set, update } = writable({
- selectedItems: new Set(),
- lastSelectedItem: null
- });
-
- function select(path: string): void {
- update((state) => {
- const newSelected = new Set(state.selectedItems);
- newSelected.add(path);
- return {
- selectedItems: newSelected,
- lastSelectedItem: path
- };
- });
- }
-
- function deselect(path: string): void {
- update((state) => {
- const newSelected = new Set(state.selectedItems);
- newSelected.delete(path);
- return {
- ...state,
- selectedItems: newSelected
- };
- });
- }
-
- function toggle(path: string): void {
- update((state) => {
- const newSelected = new Set(state.selectedItems);
- if (newSelected.has(path)) {
- newSelected.delete(path);
- } else {
- newSelected.add(path);
- }
- return {
- selectedItems: newSelected,
- lastSelectedItem: path
- };
- });
- }
-
- function selectOnly(path: string): void {
- set({
- selectedItems: new Set([path]),
- lastSelectedItem: path
- });
- }
-
- function selectAll(paths: string[]): void {
- set({
- selectedItems: new Set(paths),
- lastSelectedItem: paths[paths.length - 1] || null
- });
- }
-
- function clearSelection(): void {
- set({
- selectedItems: new Set(),
- lastSelectedItem: null
- });
- }
-
- function isSelected(path: string): boolean {
- return get({ subscribe }).selectedItems.has(path);
- }
-
- function getSelectedItems(): string[] {
- return Array.from(get({ subscribe }).selectedItems);
- }
-
- return {
- subscribe,
- select,
- deselect,
- toggle,
- selectOnly,
- selectAll,
- clearSelection,
- isSelected,
- getSelectedItems
- };
-}
-
-/**
- * Selection store singleton
- */
-export const selectionStore = createSelectionStore();
-
-/**
- * Derived store for selected items count
- */
-export const selectedCount = derived(selectionStore, ($selection) => $selection.selectedItems.size);
-
-/**
- * Derived store for whether any items are selected
- */
-export const hasSelection = derived(
- selectionStore,
- ($selection) => $selection.selectedItems.size > 0
-);
diff --git a/frontend/src/lib/stores/jobs.ts b/frontend/src/lib/stores/jobs.ts
index 37cd29b..e9a1901 100644
--- a/frontend/src/lib/stores/jobs.ts
+++ b/frontend/src/lib/stores/jobs.ts
@@ -3,18 +3,8 @@
* Requirements: 4.4
*/
-import { writable, derived, get } from 'svelte/store';
-import {
- listJobs,
- getJob,
- createJob,
- cancelJob,
- isJobActive,
- isJobTerminal,
- type Job,
- type JobState,
- type CreateJobRequest
-} from '$lib/api/jobs';
+import { writable, derived } from 'svelte/store';
+import { listJobs, isJobActive, isJobTerminal, type Job, type JobState } from '$lib/api/jobs';
/**
* Job update from WebSocket
@@ -114,60 +104,6 @@ function createJobsStore() {
});
}
- /**
- * Remove a job from the store
- */
- function removeJob(jobId: string): void {
- update((state) => {
- const newJobs = new Map(state.jobs);
- newJobs.delete(jobId);
- return { ...state, jobs: newJobs };
- });
- }
-
- /**
- * Clear all completed/failed/cancelled jobs
- */
- function clearTerminalJobs(): void {
- update((state) => {
- const newJobs = new Map();
- for (const [id, job] of state.jobs) {
- if (isJobActive(job)) {
- newJobs.set(id, job);
- }
- }
- return { ...state, jobs: newJobs };
- });
- }
-
- /**
- * Get a job by ID
- */
- function getJobById(jobId: string): Job | undefined {
- return get({ subscribe }).jobs.get(jobId);
- }
-
- /**
- * Get all jobs as an array
- */
- function getAllJobs(): Job[] {
- return Array.from(get({ subscribe }).jobs.values());
- }
-
- /**
- * Get active jobs
- */
- function getActiveJobs(): Job[] {
- return getAllJobs().filter(isJobActive);
- }
-
- /**
- * Clear error
- */
- function clearError(): void {
- update((state) => ({ ...state, error: null }));
- }
-
/**
* Reset store to initial state
*/
@@ -180,12 +116,6 @@ function createJobsStore() {
loadJobs,
upsertJob,
updateFromWebSocket,
- removeJob,
- clearTerminalJobs,
- getJobById,
- getAllJobs,
- getActiveJobs,
- clearError,
reset
};
}
@@ -195,15 +125,6 @@ function createJobsStore() {
*/
export const jobsStore = createJobsStore();
-/**
- * Derived store for jobs as array (sorted by creation time, newest first)
- */
-export const jobsList = derived(jobsStore, ($jobs) =>
- Array.from($jobs.jobs.values()).sort(
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
- )
-);
-
/**
* Derived store for active jobs only
*/
@@ -212,110 +133,3 @@ export const activeJobs = derived(jobsStore, ($jobs) =>
.filter(isJobActive)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
);
-
-/**
- * Derived store for active jobs count
- */
-export const activeJobsCount = derived(
- jobsStore,
- ($jobs) => Array.from($jobs.jobs.values()).filter(isJobActive).length
-);
-
-/**
- * Derived store for whether there are any active jobs
- */
-export const hasActiveJobs = derived(jobsStore, ($jobs) =>
- Array.from($jobs.jobs.values()).some(isJobActive)
-);
-
-/**
- * Derived store for completed jobs
- */
-export const completedJobs = derived(jobsStore, ($jobs) =>
- Array.from($jobs.jobs.values())
- .filter((job) => job.state === 'completed')
- .sort(
- (a, b) =>
- new Date(b.completedAt || b.createdAt).getTime() -
- new Date(a.completedAt || a.createdAt).getTime()
- )
-);
-
-/**
- * Derived store for failed jobs
- */
-export const failedJobs = derived(jobsStore, ($jobs) =>
- Array.from($jobs.jobs.values())
- .filter((job) => job.state === 'failed')
- .sort(
- (a, b) =>
- new Date(b.completedAt || b.createdAt).getTime() -
- new Date(a.completedAt || a.createdAt).getTime()
- )
-);
-
-/**
- * Query key factory for jobs
- */
-export const jobQueryKeys = {
- all: ['jobs'] as const,
- list: () => [...jobQueryKeys.all, 'list'] as const,
- detail: (id: string) => [...jobQueryKeys.all, 'detail', id] as const
-};
-
-/**
- * Query options factory for listing all jobs
- */
-export function jobsQueryOptions() {
- return {
- queryKey: jobQueryKeys.list(),
- queryFn: () => listJobs(),
- refetchInterval: 5000 // Refetch every 5 seconds for active jobs
- };
-}
-
-/**
- * Query options factory for a specific job
- */
-export function jobQueryOptions(jobId: string) {
- return {
- queryKey: jobQueryKeys.detail(jobId),
- queryFn: () => getJob(jobId),
- enabled: !!jobId
- };
-}
-
-/**
- * Mutation options for creating a new job
- */
-export function createJobMutationOptions() {
- return {
- mutationFn: (request: CreateJobRequest) => createJob(request),
- onSuccess: (job: Job) => {
- jobsStore.upsertJob(job);
- }
- };
-}
-
-/**
- * Mutation options for cancelling a job
- */
-export function cancelJobMutationOptions() {
- return {
- mutationFn: (jobId: string) => cancelJob(jobId),
- onSuccess: (_: unknown, jobId: string) => {
- // Update the job state locally
- const job = jobsStore.getJobById(jobId);
- if (job) {
- jobsStore.upsertJob({
- ...job,
- state: 'cancelled',
- completedAt: new Date().toISOString()
- });
- }
- }
- };
-}
-
-// Re-export utility functions
-export { isJobActive, isJobTerminal } from '$lib/api/jobs';
diff --git a/frontend/src/lib/stores/settings.ts b/frontend/src/lib/stores/settings.ts
index 6d1ba78..6c7632c 100644
--- a/frontend/src/lib/stores/settings.ts
+++ b/frontend/src/lib/stores/settings.ts
@@ -14,6 +14,12 @@ import {
normalizeBackgroundImageMode,
type BackgroundImageMode
} from '$lib/utils/wallpaper';
+import {
+ isInlineWallpaperDataUrl,
+ isLocalWallpaperReference,
+ resolveLocalWallpaperUrl,
+ saveLocalWallpaperDataUrl
+} from '$lib/utils/wallpaperStorage';
export interface UserSettings {
showHiddenFiles: boolean;
@@ -87,7 +93,8 @@ function isSupportedBackgroundImage(value: string): boolean {
return true;
}
if (value.startsWith('/') && !value.startsWith('//')) return true;
- if (/^data:image\/(avif|gif|jpeg|jpg|png|svg\+xml|webp);base64,/i.test(value)) return true;
+ if (isInlineWallpaperDataUrl(value)) return true;
+ if (isLocalWallpaperReference(value)) return true;
try {
const url = new URL(value);
@@ -126,9 +133,23 @@ export function resolveBackgroundImage(backgroundImage: string | null): string |
if (!normalized) return null;
const serverPath = getServerBackgroundPath(normalized);
+ if (isLocalWallpaperReference(normalized)) return null;
return serverPath ? getPreviewUrl(serverPath) : normalized;
}
+export async function resolveBackgroundImageUrl(
+ backgroundImage: string | null
+): Promise {
+ const normalized = normalizeBackgroundImage(backgroundImage);
+ if (!normalized) return null;
+
+ if (isLocalWallpaperReference(normalized)) {
+ return resolveLocalWallpaperUrl(normalized);
+ }
+
+ return resolveBackgroundImage(normalized);
+}
+
function parseHexColor(color: string): [number, number, number] {
const hex = color.slice(1);
return [
@@ -203,6 +224,23 @@ async function loadDriveNames(): Promise> {
}
}
+async function migrateInlineBackgroundImage(settings: UserSettings): Promise {
+ const backgroundImage = normalizeBackgroundImage(settings.backgroundImage);
+ if (!backgroundImage || !isInlineWallpaperDataUrl(backgroundImage)) {
+ return settings;
+ }
+
+ try {
+ const migratedBackgroundImage = await saveLocalWallpaperDataUrl(backgroundImage);
+ return {
+ ...settings,
+ backgroundImage: migratedBackgroundImage
+ };
+ } catch {
+ return settings;
+ }
+}
+
function createSettingsStore() {
const { subscribe, set, update } = writable(loadSettings());
@@ -210,8 +248,27 @@ function createSettingsStore() {
subscribe,
async initialize() {
+ const currentSettings = get({ subscribe });
+ const migratedSettings = await migrateInlineBackgroundImage(currentSettings);
+ if (migratedSettings !== currentSettings) {
+ try {
+ saveSettings(migratedSettings);
+ } catch {
+ // Keep the in-memory migrated reference even if the browser refuses persistence.
+ }
+ set(migratedSettings);
+ }
+
const driveNames = await loadDriveNames();
- update((current) => ({ ...current, driveNameOverrides: driveNames }));
+ update((current) => {
+ const updated = { ...current, driveNameOverrides: driveNames };
+ try {
+ saveSettings(updated);
+ } catch {
+ // Initialization should not fail auth because local preference persistence is full.
+ }
+ return updated;
+ });
},
set(settings: UserSettings) {
@@ -311,6 +368,29 @@ export const settingsStore = createSettingsStore();
// Note: initialize() should be called after successful authentication
// Do not call here as the API requires auth
+export const resolvedBackgroundImageUrl = derived(
+ settingsStore,
+ ($settings, set) => {
+ const requestedBackgroundImage = $settings.backgroundImage;
+ let cancelled = false;
+
+ set(resolveBackgroundImage(requestedBackgroundImage));
+
+ resolveBackgroundImageUrl(requestedBackgroundImage)
+ .then((url) => {
+ if (!cancelled) set(url);
+ })
+ .catch(() => {
+ if (!cancelled) set(null);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ },
+ null as string | null
+);
+
// Derived stores for individual settings
export const showHiddenFiles = derived(settingsStore, ($s) => $s.showHiddenFiles);
export const showFileExtensions = derived(settingsStore, ($s) => $s.showFileExtensions);
diff --git a/frontend/src/lib/stores/websocket.ts b/frontend/src/lib/stores/websocket.ts
index 9dd7756..288dfaf 100644
--- a/frontend/src/lib/stores/websocket.ts
+++ b/frontend/src/lib/stores/websocket.ts
@@ -5,6 +5,7 @@
import { writable, derived, get } from 'svelte/store';
import { getAccessToken } from '$lib/api/client';
+import { CONFIG } from '$lib/config';
import { jobsStore, type JobUpdate } from './jobs';
/**
@@ -64,16 +65,16 @@ const initialState: WebSocketState = {
* Exponential backoff configuration
*/
const BACKOFF_CONFIG = {
- initialDelayMs: 1000,
- maxDelayMs: 30000,
+ initialDelayMs: CONFIG.websocket.initialReconnectDelayMs,
+ maxDelayMs: CONFIG.websocket.maxReconnectDelayMs,
multiplier: 2,
- maxAttempts: 10
+ maxAttempts: CONFIG.websocket.maxReconnectAttempts
};
/**
* Ping interval for connection health
*/
-const PING_INTERVAL_MS = 30000;
+const PING_INTERVAL_MS = CONFIG.websocket.pingIntervalMs;
/**
* Create the WebSocket store
diff --git a/frontend/src/lib/utils/fileTypes.ts b/frontend/src/lib/utils/fileTypes.ts
index 246f74d..9a454d7 100644
--- a/frontend/src/lib/utils/fileTypes.ts
+++ b/frontend/src/lib/utils/fileTypes.ts
@@ -117,10 +117,56 @@ const OFFICE_PREVIEW_EXTENSIONS = new Set([
...FILE_EXTENSIONS.presentation
]);
+const SPECIAL_CODE_FILENAMES = new Set([
+ 'dockerfile',
+ 'makefile',
+ 'gnumakefile',
+ 'cmakelists.txt',
+ 'gemfile',
+ 'rakefile'
+]);
+
+const SHELL_DOTFILES = new Set([
+ '.bashrc',
+ '.bash_profile',
+ '.bash_login',
+ '.bash_logout',
+ '.profile',
+ '.zshrc',
+ '.zprofile',
+ '.zshenv',
+ '.zlogin',
+ '.zlogout',
+ '.kshrc',
+ '.cshrc',
+ '.tcshrc'
+]);
+
+const DOTFILE_LANGUAGE_MAP: Record = {
+ '.env': 'dotenv',
+ '.envrc': 'shell',
+ '.gitconfig': 'ini',
+ '.gitignore': 'ignore',
+ '.dockerignore': 'ignore',
+ '.editorconfig': 'ini',
+ '.npmrc': 'ini',
+ '.yarnrc': 'ini',
+ '.curlrc': 'shell',
+ '.wgetrc': 'ini',
+ '.vimrc': 'vim'
+};
+
+function isSingleDotfile(filename: string): boolean {
+ const baseName = filename.split('/').pop() ?? filename;
+ return baseName.startsWith('.') && baseName.indexOf('.', 1) === -1 && baseName.length > 1;
+}
+
/**
* Get the file extension from a filename
*/
export function getExtension(filename: string): string {
+ if (isSingleDotfile(filename)) return '';
+
const lastDot = filename.lastIndexOf('.');
if (lastDot === -1) return '';
return filename.slice(lastDot + 1).toLowerCase();
@@ -149,11 +195,11 @@ export function getPreviewType(filename: string): PreviewType {
// Handle special filenames without extensions
const lowerName = filename.toLowerCase();
- if (['dockerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile'].includes(lowerName)) {
+ if (SPECIAL_CODE_FILENAMES.has(lowerName)) {
return 'code';
}
- if (lowerName.startsWith('.') && !ext) {
- // Dotfiles like .gitignore, .env, etc.
+ if (isSingleDotfile(lowerName)) {
+ // Dotfiles like .bashrc, .gitignore, .env, etc.
return 'code';
}
@@ -188,7 +234,9 @@ export function getMonacoLanguage(filename: string): string {
// Special filenames
if (lowerName === 'dockerfile') return 'dockerfile';
if (lowerName === 'makefile' || lowerName === 'gnumakefile') return 'makefile';
- if (lowerName.endsWith('.gitignore') || lowerName.endsWith('.dockerignore')) return 'ignore';
+ if (SHELL_DOTFILES.has(lowerName)) return 'shell';
+ if (DOTFILE_LANGUAGE_MAP[lowerName]) return DOTFILE_LANGUAGE_MAP[lowerName];
+ if (isSingleDotfile(lowerName)) return 'plaintext';
const languageMap: Record = {
// JavaScript/TypeScript
@@ -279,7 +327,12 @@ export function canPreview(filename: string): boolean {
*/
export function getFileTypeDescription(filename: string): string {
const ext = getExtension(filename);
- if (!ext) return 'File';
+ const lowerName = filename.toLowerCase();
+
+ if (SHELL_DOTFILES.has(lowerName)) return 'Shell Config';
+ if (DOTFILE_LANGUAGE_MAP[lowerName]) return 'Config File';
+ if (isSingleDotfile(lowerName)) return 'Dotfile';
+ if (!ext) return SPECIAL_CODE_FILENAMES.has(lowerName) ? 'Code File' : 'File';
const typeMap: Record = {
// Documents
@@ -421,7 +474,9 @@ export function getFileIcon(filename: string, isDir: boolean): ComponentType {
if (isDir) return Folder;
const ext = getExtension(filename);
- if (!ext) return File;
+ if (!ext) {
+ return getPreviewType(filename) === 'code' ? FileCode : File;
+ }
// Check each category
if (FILE_EXTENSIONS.image.includes(ext as (typeof FILE_EXTENSIONS.image)[number]))
diff --git a/frontend/src/lib/utils/upload.ts b/frontend/src/lib/utils/upload.ts
index 24f1ebd..aff6bc7 100644
--- a/frontend/src/lib/utils/upload.ts
+++ b/frontend/src/lib/utils/upload.ts
@@ -7,6 +7,7 @@ import { getAccessToken } from '$lib/api/client';
import { CONFIG } from '$lib/config';
const DEFAULT_CHUNK_SIZE = CONFIG.upload.defaultChunkSize;
+const MAX_CHECKSUM_BYTES = DEFAULT_CHUNK_SIZE * 2;
// API base URL
const API_BASE_URL = '/api/v1/stream';
@@ -90,15 +91,12 @@ export async function calculateChecksum(file: File): Promise {
export async function calculateChecksumStreaming(
file: File,
chunkSize: number = DEFAULT_CHUNK_SIZE
-): Promise {
- // For smaller files, use the simple method
- if (file.size <= chunkSize * 2) {
- return calculateChecksum(file);
+): Promise {
+ const checksumLimit = Math.max(chunkSize * 2, MAX_CHECKSUM_BYTES);
+ if (file.size > checksumLimit) {
+ return undefined;
}
- // For larger files, we need to hash incrementally
- // Note: Web Crypto API doesn't support streaming, so we read the whole file
- // This is a limitation of the browser environment
return calculateChecksum(file);
}
@@ -486,139 +484,3 @@ export async function resumeUpload(
return { success: false, error: progress.error };
}
}
-
-/**
- * Upload manager for handling multiple concurrent uploads
- */
-export class UploadManager {
- private uploads: Map<
- string,
- { file: File; path: string; progress: UploadProgress; abortController: AbortController }
- > = new Map();
- private onProgressCallback?: (uploads: UploadProgress[]) => void;
-
- constructor(onProgress?: (uploads: UploadProgress[]) => void) {
- this.onProgressCallback = onProgress;
- }
-
- /**
- * Add a file to the upload queue and start uploading
- */
- async addUpload(
- file: File,
- destinationPath: string,
- options: Omit = {}
- ): Promise {
- const uploadId = generateUploadId();
- const abortController = new AbortController();
-
- const progress: UploadProgress = {
- uploadId,
- fileName: file.name,
- totalSize: file.size,
- uploadedSize: 0,
- percentage: 0,
- currentChunk: 0,
- totalChunks: getChunkCount(file.size, options.chunkSize),
- status: 'pending'
- };
-
- this.uploads.set(uploadId, { file, path: destinationPath, progress, abortController });
- this.notifyProgress();
-
- // Start upload in background
- this.startUpload(uploadId, options);
-
- return uploadId;
- }
-
- /**
- * Start or resume an upload
- */
- private async startUpload(
- uploadId: string,
- options: Omit = {}
- ): Promise {
- const upload = this.uploads.get(uploadId);
- if (!upload) return;
-
- const result = await uploadFile(upload.file, upload.path, {
- ...options,
- uploadId,
- signal: upload.abortController.signal,
- onProgress: (progress) => {
- const existing = this.uploads.get(uploadId);
- if (existing) {
- existing.progress = progress;
- this.notifyProgress();
- }
- }
- });
-
- if (!result.success && upload.progress.status !== 'cancelled') {
- upload.progress.status = 'error';
- upload.progress.error = result.error;
- this.notifyProgress();
- }
- }
-
- /**
- * Cancel an upload
- */
- cancelUpload(uploadId: string): void {
- const upload = this.uploads.get(uploadId);
- if (upload) {
- upload.abortController.abort();
- upload.progress.status = 'cancelled';
- this.notifyProgress();
- }
- }
-
- /**
- * Remove an upload from the manager
- */
- removeUpload(uploadId: string): void {
- const upload = this.uploads.get(uploadId);
- if (upload) {
- upload.abortController.abort();
- this.uploads.delete(uploadId);
- this.notifyProgress();
- }
- }
-
- /**
- * Get all upload progress
- */
- getUploads(): UploadProgress[] {
- return Array.from(this.uploads.values()).map((u) => ({ ...u.progress }));
- }
-
- /**
- * Get a specific upload's progress
- */
- getUpload(uploadId: string): UploadProgress | undefined {
- const upload = this.uploads.get(uploadId);
- return upload ? { ...upload.progress } : undefined;
- }
-
- /**
- * Notify progress callback
- */
- private notifyProgress(): void {
- if (this.onProgressCallback) {
- this.onProgressCallback(this.getUploads());
- }
- }
-
- /**
- * Clear completed/failed/cancelled uploads
- */
- clearFinished(): void {
- for (const [id, upload] of this.uploads) {
- if (['complete', 'error', 'cancelled'].includes(upload.progress.status)) {
- this.uploads.delete(id);
- }
- }
- this.notifyProgress();
- }
-}
diff --git a/frontend/src/lib/utils/wallpaper.ts b/frontend/src/lib/utils/wallpaper.ts
index 617f6ac..baa9ca8 100644
--- a/frontend/src/lib/utils/wallpaper.ts
+++ b/frontend/src/lib/utils/wallpaper.ts
@@ -52,11 +52,21 @@ export function isWallpaperImageFile(item: FileInfo): boolean {
);
}
-export function readFileAsDataUrl(file: File): Promise {
+export function readFileAsDataUrl(
+ file: File,
+ onProgress?: (progress: number) => void
+): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
+ reader.onloadstart = () => onProgress?.(0);
+ reader.onprogress = (event) => {
+ if (event.lengthComputable && event.total > 0) {
+ onProgress?.(Math.round((event.loaded / event.total) * 100));
+ }
+ };
reader.onload = () => {
if (typeof reader.result === 'string') {
+ onProgress?.(100);
resolve(reader.result);
} else {
reject(new Error('Unsupported file result'));
diff --git a/frontend/src/lib/utils/wallpaperStorage.ts b/frontend/src/lib/utils/wallpaperStorage.ts
new file mode 100644
index 0000000..bc10f2b
--- /dev/null
+++ b/frontend/src/lib/utils/wallpaperStorage.ts
@@ -0,0 +1,179 @@
+const DATABASE_NAME = 'boxbox-wallpapers';
+const DATABASE_VERSION = 1;
+const STORE_NAME = 'wallpapers';
+const LOCAL_WALLPAPER_PREFIX = 'boxbox-local-wallpaper:';
+const LOCAL_WALLPAPER_ID_PATTERN = /^[a-z0-9_-]{8,}$/i;
+const INLINE_WALLPAPER_PATTERN = /^data:image\/(avif|gif|jpeg|jpg|png|svg\+xml|webp);base64,/i;
+
+interface StoredLocalWallpaper {
+ id: string;
+ blob: Blob;
+ type: string;
+ createdAt: number;
+}
+
+const localWallpaperUrlCache = new Map();
+
+export function isInlineWallpaperDataUrl(value: string): boolean {
+ return INLINE_WALLPAPER_PATTERN.test(value.trim());
+}
+
+export function toLocalWallpaperReference(id: string): string {
+ return `${LOCAL_WALLPAPER_PREFIX}${id}`;
+}
+
+export function getLocalWallpaperId(reference: string): string | null {
+ const trimmed = reference.trim();
+ if (!trimmed.startsWith(LOCAL_WALLPAPER_PREFIX)) return null;
+
+ const id = trimmed.slice(LOCAL_WALLPAPER_PREFIX.length);
+ return LOCAL_WALLPAPER_ID_PATTERN.test(id) ? id : null;
+}
+
+export function isLocalWallpaperReference(value: string): boolean {
+ return getLocalWallpaperId(value) !== null;
+}
+
+export async function saveLocalWallpaperDataUrl(dataUrl: string): Promise {
+ if (!isInlineWallpaperDataUrl(dataUrl)) {
+ throw new Error('Only image data URLs can be saved as local wallpapers.');
+ }
+
+ const id = createLocalWallpaperId();
+ const blob = dataUrlToBlob(dataUrl);
+ const database = await openWallpaperDatabase();
+
+ try {
+ const transaction = database.transaction(STORE_NAME, 'readwrite');
+ transaction.objectStore(STORE_NAME).put({
+ id,
+ blob,
+ type: blob.type,
+ createdAt: Date.now()
+ } satisfies StoredLocalWallpaper);
+ await waitForTransaction(transaction);
+ } finally {
+ database.close();
+ }
+
+ return toLocalWallpaperReference(id);
+}
+
+export async function resolveLocalWallpaperUrl(reference: string): Promise {
+ const id = getLocalWallpaperId(reference);
+ if (!id) return null;
+
+ const cachedUrl = localWallpaperUrlCache.get(id);
+ if (cachedUrl) return cachedUrl;
+
+ const database = await openWallpaperDatabase();
+
+ try {
+ const transaction = database.transaction(STORE_NAME, 'readonly');
+ const request = transaction.objectStore(STORE_NAME).get(id) as IDBRequest<
+ StoredLocalWallpaper | undefined
+ >;
+ const wallpaper = await requestToPromise(request);
+ await waitForTransaction(transaction);
+
+ if (!wallpaper) return null;
+
+ const url = URL.createObjectURL(wallpaper.blob);
+ localWallpaperUrlCache.set(id, url);
+ return url;
+ } finally {
+ database.close();
+ }
+}
+
+export async function deleteLocalWallpaper(reference: string | null): Promise {
+ if (!reference) return;
+
+ const id = getLocalWallpaperId(reference);
+ if (!id) return;
+
+ revokeCachedLocalWallpaperUrl(id);
+
+ const database = await openWallpaperDatabase();
+
+ try {
+ const transaction = database.transaction(STORE_NAME, 'readwrite');
+ transaction.objectStore(STORE_NAME).delete(id);
+ await waitForTransaction(transaction);
+ } finally {
+ database.close();
+ }
+}
+
+function openWallpaperDatabase(): Promise {
+ if (typeof indexedDB === 'undefined') {
+ throw new Error('Local wallpaper storage is not available in this browser.');
+ }
+
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+
+ request.onupgradeneeded = () => {
+ const database = request.result;
+ if (!database.objectStoreNames.contains(STORE_NAME)) {
+ database.createObjectStore(STORE_NAME, { keyPath: 'id' });
+ }
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () =>
+ reject(request.error ?? new Error('Unable to open local wallpaper storage.'));
+ request.onblocked = () =>
+ reject(new Error('Local wallpaper storage is blocked by another browser tab.'));
+ });
+}
+
+function requestToPromise(request: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error('Local wallpaper storage failed.'));
+ });
+}
+
+function waitForTransaction(transaction: IDBTransaction): Promise {
+ return new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve();
+ transaction.onabort = () =>
+ reject(transaction.error ?? new Error('Local wallpaper storage was aborted.'));
+ transaction.onerror = () =>
+ reject(transaction.error ?? new Error('Local wallpaper storage failed.'));
+ });
+}
+
+function dataUrlToBlob(dataUrl: string): Blob {
+ const commaIndex = dataUrl.indexOf(',');
+ if (commaIndex === -1) throw new Error('Invalid image data URL.');
+
+ const metadata = dataUrl.slice(0, commaIndex);
+ const encoded = dataUrl.slice(commaIndex + 1);
+ const mimeType = metadata.match(/^data:([^;]+);base64$/i)?.[1];
+ if (!mimeType?.startsWith('image/')) throw new Error('Invalid image data URL.');
+
+ const binary = atob(encoded);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
+ }
+
+ return new Blob([bytes], { type: mimeType });
+}
+
+function createLocalWallpaperId(): string {
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
+ return crypto.randomUUID();
+ }
+
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+}
+
+function revokeCachedLocalWallpaperUrl(id: string): void {
+ const cachedUrl = localWallpaperUrlCache.get(id);
+ if (!cachedUrl) return;
+
+ URL.revokeObjectURL(cachedUrl);
+ localWallpaperUrlCache.delete(id);
+}
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index eea0b7e..bd9e9ed 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -12,7 +12,11 @@
import { FolderOpen } from 'lucide-svelte';
import { activeJobs, jobsStore } from '$lib/stores/jobs';
import { websocketStore } from '$lib/stores/websocket';
- import { applyAccentColor, resolveBackgroundImage, settingsStore } from '$lib/stores/settings';
+ import {
+ applyAccentColor,
+ resolvedBackgroundImageUrl,
+ settingsStore
+ } from '$lib/stores/settings';
import { getWallpaperBackgroundStyle, normalizeBackgroundImageMode } from '$lib/utils/wallpaper';
let { children } = $props();
@@ -29,15 +33,15 @@
});
// Public routes that don't require authentication
- const publicRoutes = ['/login', '/test'];
+ const publicRoutes = ['/login'];
const isWorkspacePage = $derived(
page.url.pathname.startsWith('/browse') || page.url.pathname.startsWith('/settings')
);
const isLoginPage = $derived(page.url.pathname.startsWith('/login'));
- const backgroundImage = $derived(resolveBackgroundImage($settingsStore.backgroundImage));
const backgroundImageMode = $derived(
normalizeBackgroundImageMode($settingsStore.backgroundImageMode)
);
+ const backgroundImage = $derived($resolvedBackgroundImageUrl);
const hasBackgroundImage = $derived(backgroundImage !== null);
const frostedGlass = $derived(hasBackgroundImage && $settingsStore.frostedGlass);
const backgroundImageStyle = $derived(
diff --git a/frontend/src/routes/browse/+page.svelte b/frontend/src/routes/browse/+page.svelte
index c443289..fd25ec4 100644
--- a/frontend/src/routes/browse/+page.svelte
+++ b/frontend/src/routes/browse/+page.svelte
@@ -656,7 +656,7 @@
-
+
@@ -679,6 +679,7 @@
searchLoading={isSearchActive && searchQueryResult.isFetching}
onSearchInput={handleSearchInput}
onSearchClear={handleSearchClear}
+ includeHiddenSuggestions={settings.showHiddenFiles}
/>
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte
index a30fbd4..50f1166 100644
--- a/frontend/src/routes/settings/+page.svelte
+++ b/frontend/src/routes/settings/+page.svelte
@@ -2,6 +2,7 @@
/**
* Settings page - workspace-style preferences screen matching the file browser shell.
*/
+ import { onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth';
@@ -16,8 +17,14 @@
} from '$lib/stores/settings';
import SearchBar from '$lib/components/SearchBar.svelte';
import WallpaperSettings from '$lib/components/settings/wallpaper/WallpaperSettings.svelte';
- import { Button, Select, Toggle } from '$lib/components/ui';
+ import { Button, ProgressButton, Select, Toggle } from '$lib/components/ui';
import { normalizeBackgroundImageMode } from '$lib/utils/wallpaper';
+ import {
+ deleteLocalWallpaper,
+ isInlineWallpaperDataUrl,
+ isLocalWallpaperReference,
+ saveLocalWallpaperDataUrl
+ } from '$lib/utils/wallpaperStorage';
import {
ChevronLeft,
Eye,
@@ -35,10 +42,16 @@
type SettingsSectionId = 'display' | 'personalization' | 'behavior' | 'defaults' | 'account';
type SettingsCategory = 'all' | SettingsSectionId;
+ type ApplyProgressVariant = 'default' | 'success' | 'danger';
let settings = $state
({ ...$settingsStore });
let activeCategory = $state('all');
let searchQuery = $state('');
+ let isApplyingSettings = $state(false);
+ let applyProgress = $state(0);
+ let applyProgressStatus = $state('');
+ let applyProgressVariant = $state('default');
+ let applyProgressResetTimer: ReturnType | null = null;
const hasChanges = $derived(JSON.stringify(settings) !== JSON.stringify($settingsStore));
const normalizedSearch = $derived(searchQuery.trim().toLowerCase());
@@ -47,7 +60,15 @@
normalizeAccentColor(settings.accentColor) ?? DEFAULT_ACCENT_COLOR
);
const backgroundImageIsValid = $derived(isValidBackgroundImage(settings.backgroundImage));
- const canSave = $derived(hasChanges && accentColorIsValid && backgroundImageIsValid);
+ const canSave = $derived(
+ hasChanges && accentColorIsValid && backgroundImageIsValid && !isApplyingSettings
+ );
+ const showApplyProgress = $derived(isApplyingSettings || applyProgress > 0);
+ const saveButtonText = $derived.by(() => {
+ if (applyProgressVariant === 'danger' && applyProgress > 0) return 'Failed';
+ if (applyProgressVariant === 'success' && applyProgress >= 100) return 'Saved';
+ return isApplyingSettings ? 'Saving' : 'Save';
+ });
const navItems: Array<{
id: SettingsCategory;
@@ -114,29 +135,145 @@
const panelHeaderClass =
'flex items-start justify-between gap-4 border-b border-border-secondary bg-surface-primary/55 px-4 py-3';
const settingRowClass =
- 'flex items-center justify-between gap-4 border-b border-border-secondary px-4 py-3 last:border-b-0';
+ 'flex min-h-12 items-center justify-between gap-4 border-b border-border-secondary px-4 py-2 last:border-b-0';
+
+ onDestroy(() => {
+ clearApplyProgressResetTimer();
+ });
+
+ function clearApplyProgressResetTimer() {
+ if (!applyProgressResetTimer) return;
- function handleSave() {
- if (!accentColorIsValid || !backgroundImageIsValid) return;
+ clearTimeout(applyProgressResetTimer);
+ applyProgressResetTimer = null;
+ }
+
+ function scheduleApplyProgressReset(delay: number) {
+ clearApplyProgressResetTimer();
+ applyProgressResetTimer = setTimeout(() => {
+ applyProgress = 0;
+ applyProgressStatus = '';
+ applyProgressVariant = 'default';
+ applyProgressResetTimer = null;
+ }, delay);
+ }
- const backgroundImage = normalizeBackgroundImage(settings.backgroundImage);
+ function waitForPaint(): Promise {
+ return new Promise((resolvePaint) => {
+ if (typeof requestAnimationFrame === 'undefined') {
+ resolvePaint();
+ return;
+ }
- settingsStore.set({
- ...settings,
- accentColor: normalizeAccentColor(settings.accentColor),
- backgroundImage,
- backgroundImageMode: normalizeBackgroundImageMode(settings.backgroundImageMode),
- frostedGlass: backgroundImage ? settings.frostedGlass : false
+ requestAnimationFrame(() => resolvePaint());
+ });
+ }
+
+ async function setApplyProgress(value: number, status: string) {
+ applyProgress = value;
+ applyProgressStatus = status;
+ await tick();
+ await waitForPaint();
+ }
+
+ async function handleSave() {
+ if (!accentColorIsValid || !backgroundImageIsValid || isApplyingSettings) return;
+
+ clearApplyProgressResetTimer();
+ isApplyingSettings = true;
+ applyProgressVariant = 'default';
+ let savedLocalBackgroundImage: string | null = null;
+
+ try {
+ await setApplyProgress(15, 'Validating settings...');
+
+ const previousBackgroundImage = $settingsStore.backgroundImage;
+ let backgroundImage = normalizeBackgroundImage(settings.backgroundImage);
+ if (backgroundImage && isInlineWallpaperDataUrl(backgroundImage)) {
+ await setApplyProgress(35, 'Saving wallpaper locally...');
+ backgroundImage = await saveLocalWallpaperDataUrl(backgroundImage);
+ savedLocalBackgroundImage = backgroundImage;
+ }
+
+ const nextSettings = {
+ ...settings,
+ accentColor: normalizeAccentColor(settings.accentColor),
+ backgroundImage,
+ backgroundImageMode: normalizeBackgroundImageMode(settings.backgroundImageMode),
+ frostedGlass: backgroundImage ? settings.frostedGlass : false
+ };
+
+ await setApplyProgress(
+ 55,
+ backgroundImage ? 'Applying wallpaper and preferences...' : 'Applying preferences...'
+ );
+
+ settingsStore.set(nextSettings);
+ settings = { ...nextSettings };
+
+ await setApplyProgress(85, 'Refreshing workspace...');
+ cleanupLocalWallpaper(previousBackgroundImage, backgroundImage);
+ applyProgressVariant = 'success';
+ await setApplyProgress(100, 'Settings applied');
+ scheduleApplyProgressReset(900);
+ } catch (error) {
+ cleanupLocalWallpaper(savedLocalBackgroundImage, null);
+ applyProgressVariant = 'danger';
+ applyProgress = 100;
+ applyProgressStatus = getApplyErrorMessage(error);
+ scheduleApplyProgressReset(4000);
+ } finally {
+ isApplyingSettings = false;
+ }
+ }
+
+ function getApplyErrorMessage(error: unknown): string {
+ if (isStorageQuotaError(error)) {
+ return 'This wallpaper is too large to save locally. Choose a smaller image or pick one from the server.';
+ }
+
+ return error instanceof Error ? error.message : 'Unable to apply settings.';
+ }
+
+ function isStorageQuotaError(error: unknown): boolean {
+ const errorText = error instanceof Error ? `${error.name} ${error.message}` : String(error);
+ return /quota|NS_ERROR_DOM_QUOTA_REACHED|exceeded/i.test(errorText);
+ }
+
+ function cleanupLocalWallpaper(
+ previousBackgroundImage: string | null,
+ nextBackgroundImage: string | null
+ ) {
+ if (
+ !previousBackgroundImage ||
+ previousBackgroundImage === nextBackgroundImage ||
+ !isLocalWallpaperReference(previousBackgroundImage)
+ ) {
+ return;
+ }
+
+ deleteLocalWallpaper(previousBackgroundImage).catch(() => {
+ // Cleanup failure should not make an already saved preference look failed.
});
}
function handleCancel() {
+ clearApplyProgressResetTimer();
+ applyProgress = 0;
+ applyProgressStatus = '';
+ applyProgressVariant = 'default';
settings = { ...$settingsStore };
}
function handleReset() {
+ clearApplyProgressResetTimer();
+ applyProgress = 0;
+ applyProgressStatus = '';
+ applyProgressVariant = 'default';
+ const previousBackgroundImage = $settingsStore.backgroundImage;
settingsStore.reset();
settings = { ...$settingsStore };
+ cleanupLocalWallpaper(previousBackgroundImage, null);
}
async function handleLogout() {
@@ -300,21 +437,26 @@
size="sm"
onclick={handleCancel}
title="Discard changes"
- disabled={!hasChanges}
+ disabled={!hasChanges || isApplyingSettings}
>
Cancel
-
- Save
-
+ {saveButtonText}
+
diff --git a/frontend/src/routes/test/+page.svelte b/frontend/src/routes/test/+page.svelte
deleted file mode 100644
index a8b7673..0000000
--- a/frontend/src/routes/test/+page.svelte
+++ /dev/null
@@ -1,532 +0,0 @@
-
-
-
- Component Test Page
-
-
-
-
Component Test Page
-
Test all UI components without backend connection
-
-
-
-
-
-
- Base UI Components
-
-
-
-
Buttons
-
- Primary
- Secondary
- Ghost
- Danger
- Disabled
- Small
- Large
-
-
-
-
-
-
-
Inputs
-
-
-
-
Value: {inputValue || '(empty)'}
-
-
-
-
-
-
-
-
-
-
-
-
-
Select
-
-
-
Selected: {selectValue}
-
-
-
-
-
-
Toggle
-
-
-
State: {toggleChecked ? 'ON' : 'OFF'}
-
-
-
-
-
-
-
Badges
-
- Default
- Success
- Warning
- Danger
- Info
-
-
-
-
-
-
Spinner
-
-
-
- Small
-
-
-
- Medium
-
-
-
- Large
-
-
-
-
-
-
-
-
-
-
Cards
-
-
- Default Card
- This is a basic card component.
-
-
console.log('Card clicked!')}>
- Interactive Card
- Click me! I'm interactive.
-
-
- Large Padding
- This card has more padding.
-
-
-
-
-
-
-
Modal
-
modalOpen = true}>Open Modal
-
modalOpen = false}>
- {#snippet children()}
- This is the modal content. You can put anything here.
-
-
-
- {/snippet}
- {#snippet footer()}
- modalOpen = false}>Cancel
- modalOpen = false}>Confirm
- {/snippet}
-
-
-
-
-
-
- Context Menu
-
-
-
Right-click anywhere to open context menu
-
-
{
- e.preventDefault();
- contextMenuX = e.clientX;
- contextMenuY = e.clientY;
- contextMenuOpen = true;
- }}
- >
- Right-click Area
-
-
- {#if contextMenuOpen}
-
{
- console.log('Context menu item selected:', id);
- contextMenuOpen = false;
- }}
- onClose={() => contextMenuOpen = false}
- />
- {/if}
-
-
-
-
-
- File Manager Components
-
-
-
-
Breadcrumb
-
-
-
-
-
-
SearchBar
-
-
console.log('Search:', q)}
- onInput={(q) => { searchQuery = q; console.log('Input:', q); }}
- />
-
-
-
-
-
-
-
-
-
Toolbar
-
- console.log('Back')}
- onForward={() => console.log('Forward')}
- onUp={() => console.log('Up')}
- onNavigate={handleNavigate}
- onRefresh={() => console.log('Refresh')}
- onSettings={() => console.log('Settings')}
- />
-
-
-
-
-
-
StatusBar
-
- viewMode = mode}
- />
-
-
-
-
-
-
SystemDriveCard
-
- {#each mockSystemDrives as drive (drive.mountPoint)}
- console.log('Drive clicked:', drive.mountPoint)} />
- {/each}
-
-
-
-
-
-
-
-
-
FileList
-
- { sortBy = field; sortDir = dir; }}
- onSelectionChange={(paths) => selectedPaths = paths}
- />
-
-
Selected: {selectedPaths.size} items
-
-
-
-
-
FileGrid
-
- selectedPaths = paths}
- />
-
-
Selected: {selectedPaths.size} items
-
-
-
-
-
- Upload & Job Components
-
-
-
-
-
-
-
UploadProgress
-
- console.log('Cancel upload:', id)}
- onRemove={(id) => console.log('Remove upload:', id)}
- onClearCompleted={() => console.log('Clear completed uploads')}
- />
-
-
-
-
-
-
JobMonitor
-
- console.log('Cancel job:', id)}
- onRemove={(id) => console.log('Remove job:', id)}
- onClearCompleted={() => console.log('Clear completed jobs')}
- />
-
-
-
-
-
-
- Design Tokens (Color Palette)
-
-
-
-
-
Surfaces
-
surface-primary
-
surface-secondary
-
surface-tertiary
-
surface-elevated
-
-
-
-
-
Text
-
text-primary
-
text-secondary
-
text-muted
-
text-disabled
-
-
-
-
-
Accent
-
accent
-
accent-hover
-
accent-muted
-
selection
-
-
-
-
-
Semantic
-
success
-
warning
-
danger
-
folder
-
-
-
-
-
- Component Test Page - No backend required
-
-
diff --git a/scripts/local-test.ts b/scripts/local-test.ts
index b0bc0e5..ef69030 100644
--- a/scripts/local-test.ts
+++ b/scripts/local-test.ts
@@ -396,32 +396,13 @@ function isPortAvailable(host: string, port: number): Promise {
});
}
-async function isPortListening(port: number): Promise {
- const proc = spawn({
- cmd: ["ss", "-ltnH"],
- stdout: "pipe",
- stderr: "ignore",
- });
-
- const output = await new Response(proc.stdout).text();
- await proc.exited;
-
- if (proc.exitCode !== 0) {
- return false;
- }
-
- return output
- .split("\n")
- .some((line) => line.trim().split(/\s+/)[3]?.endsWith(`:${port}`));
-}
-
async function findAvailablePort(
host: string,
preferredPort: number,
label: string,
): Promise {
for (let port = preferredPort; port < preferredPort + 50; port++) {
- if ((await isPortAvailable(host, port)) && !(await isPortListening(port))) {
+ if (await isPortAvailable(host, port)) {
if (port !== preferredPort) {
console.log(
`${colors.yellow}${label} port ${preferredPort} is busy; using ${port}.${colors.reset}`,
diff --git a/website/src/components/AppInterfaceDemo.astro b/website/src/components/AppInterfaceDemo.astro
index 0263014..5acbf10 100644
--- a/website/src/components/AppInterfaceDemo.astro
+++ b/website/src/components/AppInterfaceDemo.astro
@@ -211,34 +211,42 @@ import {
const appWindow = document.getElementById('app-window');
if (appWindow) {
+ let ticking = false;
+
+ function updateDemoTransform() {
+ const scrollY = window.scrollY;
+ // Adjust these values to tune the animation timing
+ const maxScroll = window.innerHeight * 0.6;
+
+ let progress = Math.min(Math.max(scrollY / maxScroll, 0), 1);
+
+ // Use an easing function for smoother landing
+ // EaseOutQuart
+ const easeProgress = 1 - Math.pow(1 - progress, 4);
+
+ // Calculate transform values based on progress
+ const rotateX = 20 - (20 * easeProgress); // 20deg to 0deg
+ const scale = 0.85 + (0.15 * easeProgress); // 0.85 to 1.0
+ const translateY = -50 * (1 - easeProgress); // move up slightly as it rotates
+
+ appWindow.style.transform = `perspective(2000px) translateY(${translateY}px) rotateX(${rotateX}deg) scale(${scale})`;
+
+ if (progress > 0.8) {
+ appWindow.classList.add('shadow-accent/30');
+ appWindow.classList.remove('shadow-black/60');
+ } else {
+ appWindow.classList.add('shadow-black/60');
+ appWindow.classList.remove('shadow-accent/30');
+ }
+ }
+
window.addEventListener('scroll', () => {
- // Use requestAnimationFrame for smooth performance
+ if (ticking) return;
+ ticking = true;
window.requestAnimationFrame(() => {
- const scrollY = window.scrollY;
- // Adjust these values to tune the animation timing
- const maxScroll = window.innerHeight * 0.6;
-
- let progress = Math.min(Math.max(scrollY / maxScroll, 0), 1);
-
- // Use an easing function for smoother landing
- // EaseOutQuart
- const easeProgress = 1 - Math.pow(1 - progress, 4);
-
- // Calculate transform values based on progress
- const rotateX = 20 - (20 * easeProgress); // 20deg to 0deg
- const scale = 0.85 + (0.15 * easeProgress); // 0.85 to 1.0
- const translateY = -50 * (1 - easeProgress); // move up slightly as it rotates
-
- appWindow.style.transform = `perspective(2000px) translateY(${translateY}px) rotateX(${rotateX}deg) scale(${scale})`;
-
- if (progress > 0.8) {
- appWindow.classList.add('shadow-accent/30');
- appWindow.classList.remove('shadow-black/60');
- } else {
- appWindow.classList.add('shadow-black/60');
- appWindow.classList.remove('shadow-accent/30');
- }
+ updateDemoTransform();
+ ticking = false;
});
- });
+ }, { passive: true });
}
-
\ No newline at end of file
+
diff --git a/website/src/components/FeatureCard.astro b/website/src/components/FeatureCard.astro
deleted file mode 100644
index 7a0ba41..0000000
--- a/website/src/components/FeatureCard.astro
+++ /dev/null
@@ -1,13 +0,0 @@
----
-
-// Since this is Astro using @lucide/astro, the Icon prop is an Astro component.
-const { title, desc, icon: Icon } = Astro.props;
----
-
-
-
-
-
-
{title}
-
{desc}
-
diff --git a/website/src/components/FeaturesBento.astro b/website/src/components/FeaturesBento.astro
index 65922c2..49d750a 100644
--- a/website/src/components/FeaturesBento.astro
+++ b/website/src/components/FeaturesBento.astro
@@ -5,7 +5,6 @@ import {
Code,
Zap,
CirclePlay,
- Play,
Box,
Terminal,
FileCode,
diff --git a/website/src/layouts/DocsLayout.astro b/website/src/layouts/DocsLayout.astro
index 15a7a09..8b35ee8 100644
--- a/website/src/layouts/DocsLayout.astro
+++ b/website/src/layouts/DocsLayout.astro
@@ -1,6 +1,6 @@
---
import Layout from './Layout.astro';
-import { ArrowLeft, ExternalLink, Github } from '@lucide/astro';
+import { ArrowLeft, Github } from '@lucide/astro';
import type { DocHeading, DocNavItem, DocPage } from '../lib/docs';
interface Props {
diff --git a/website/src/lib/docs.ts b/website/src/lib/docs.ts
index ad9759a..eec0ed0 100644
--- a/website/src/lib/docs.ts
+++ b/website/src/lib/docs.ts
@@ -22,56 +22,55 @@ export type DocPage = DocNavItem & {
const docsDirectory = resolveDocsDirectory();
-const docMeta: Record> = {
- index: {
+const docNav = [
+ {
+ slug: 'index',
title: 'Overview',
description: 'Start here: what BoxBox is and where to find each project reference.'
},
- quickstart: {
+ {
+ slug: 'quickstart',
title: 'Quick Start',
description: 'Install BoxBox quickly with Docker Compose and the published GHCR image.'
},
- docker: {
+ {
+ slug: 'docker',
title: 'Docker Deployment',
description: 'Deploy from GHCR with Compose, port binding, and optional reverse proxy examples.'
},
- configuration: {
+ {
+ slug: 'configuration',
title: 'Configuration',
description: 'Configure users, mount points, origins, upload limits, ports, and environment overrides.'
},
- api: {
+ {
+ slug: 'api',
title: 'API Reference',
description: 'REST endpoints, chunked uploads, streaming previews, background jobs, and WebSocket messages.'
},
- development: {
+ {
+ slug: 'development',
title: 'Development',
description: 'Run the Go backend, SvelteKit app, and Astro website locally.'
},
- security: {
+ {
+ slug: 'security',
title: 'Security',
description: 'Harden credentials, mounted paths, origins, reverse proxy behavior, and upload storage.'
},
- architecture: {
+ {
+ slug: 'architecture',
title: 'Architecture',
description: 'How the Go server, embedded SvelteKit app, services, jobs, and storage paths fit together.'
},
- troubleshooting: {
+ {
+ slug: 'troubleshooting',
title: 'Troubleshooting',
description: 'Fix common deployment, login, mount, upload, preview, and WebSocket issues.'
}
-};
+] satisfies DocNavItem[];
-const navOrder = [
- 'index',
- 'quickstart',
- 'docker',
- 'configuration',
- 'api',
- 'development',
- 'security',
- 'architecture',
- 'troubleshooting'
-];
+const docMeta = new Map(docNav.map(({ slug, ...meta }) => [slug, meta]));
marked.setOptions({
gfm: true,
@@ -80,15 +79,7 @@ marked.setOptions({
export function getDocNav(): DocNavItem[] {
const available = new Set(getDocSlugs());
- return navOrder
- .filter((slug) => available.has(slug))
- .map((slug) => ({
- slug,
- ...(docMeta[slug] ?? {
- title: titleFromSlug(slug),
- description: ''
- })
- }));
+ return docNav.filter((doc) => available.has(doc.slug));
}
export function getDocSlugs(): string[] {
@@ -104,7 +95,7 @@ export async function getDocPage(slug: string): Promise {
const linked = normalizeMarkdownDocLinks(rendered);
const highlighted = await highlightCodeBlocks(linked);
const { html, headings } = addHeadingIds(highlighted);
- const meta = docMeta[normalizedSlug] ?? {
+ const meta = docMeta.get(normalizedSlug) ?? {
title: titleFromMarkdown(markdown) ?? titleFromSlug(normalizedSlug),
description: ''
};
diff --git a/website/src/styles/global.css b/website/src/styles/global.css
index 8dbc049..6155133 100644
--- a/website/src/styles/global.css
+++ b/website/src/styles/global.css
@@ -70,15 +70,6 @@ body {
border: 1px solid rgba(255, 255, 255, 0.05);
}
-.feature-card {
- transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
-}
-.feature-card:hover {
- transform: translateY(-8px);
- background: var(--color-surface-secondary);
- border-color: var(--color-border-focus);
-}
-
/* Custom scrollbar for the mock UI */
.mock-scrollbar::-webkit-scrollbar {
width: 6px;