762 lines
35 KiB
Svelte
762 lines
35 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { flip } from 'svelte/animate';
|
|
import { quintOut } from 'svelte/easing';
|
|
import { crossfade } from 'svelte/transition';
|
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
import { getVersion } from '@tauri-apps/api/app';
|
|
import { relaunch } from '@tauri-apps/plugin-process';
|
|
import { check, type Update } from '@tauri-apps/plugin-updater';
|
|
import {
|
|
Archive,
|
|
Check,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Columns3,
|
|
LayoutList,
|
|
LoaderCircle,
|
|
Minus,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
Settings,
|
|
Square,
|
|
Trash2,
|
|
X
|
|
} from '@lucide/svelte';
|
|
import { cssColor, normalizeColor, textColor } from './lib/color';
|
|
import { loadPassword, removePassword, savePassword } from './lib/credentials';
|
|
import { cardUpdateFrom, DeckApiClient } from './lib/deck-api';
|
|
import { loadSettings, saveSettings } from './lib/settings';
|
|
import { moveCard as moveCardLocally, moveStack as moveStackLocally } from './lib/reorder';
|
|
import type { Board, BoardOrientation, Card, CardDraft, Stack } from './lib/types';
|
|
|
|
let settings = loadSettings();
|
|
let serverUrl = settings.serverUrl;
|
|
let username = settings.username;
|
|
let appPassword = '';
|
|
let rememberPassword = true;
|
|
let client: DeckApiClient | null = null;
|
|
let boards: Board[] = [];
|
|
let stacks: Stack[] = [];
|
|
let selectedBoard: Board | null = null;
|
|
let busy = false;
|
|
let status = 'Connect to Nextcloud to load your boards.';
|
|
let search = '';
|
|
let settingsOpen = false;
|
|
let colorPickerOpen = false;
|
|
let dragKind: 'card' | 'stack' | null = null;
|
|
let draggedCard: { cardId: number; sourceStackId: number } | null = null;
|
|
let draggedStackId: number | null = null;
|
|
let dragOriginStacks: Stack[] | null = null;
|
|
let dragGhost: { kind: 'card' | 'stack'; title: string; color?: string | null; x: number; y: number; width: number; offsetX: number; offsetY: number } | null = null;
|
|
let reduceMotion = false;
|
|
let currentVersion = '0.2.0';
|
|
let pendingUpdate: Update | null = null;
|
|
let updateState: 'idle' | 'checking' | 'available' | 'current' | 'downloading' | 'installing' | 'error' = 'idle';
|
|
let updateMessage = 'Updates are checked securely against releases from git.elijahkuntz.com.';
|
|
let updateProgress = 0;
|
|
let editor: { mode: 'create' | 'edit'; stackId: number; card?: Card; draft: CardDraft } | null = null;
|
|
const appWindow = '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null;
|
|
const [sendCard, receiveCard] = crossfade({
|
|
duration: (distance) => reduceMotion ? 0 : Math.min(190, Math.max(120, Math.sqrt(distance * 220))),
|
|
easing: quintOut
|
|
});
|
|
let pointerMoveHandler: ((event: PointerEvent) => void) | null = null;
|
|
let pointerUpHandler: ((event: PointerEvent) => void) | null = null;
|
|
let pointerCancelHandler: (() => void) | null = null;
|
|
let keyCancelHandler: ((event: KeyboardEvent) => void) | null = null;
|
|
|
|
const colorPresets = [
|
|
'C84DA5', 'D66A91', 'E89989', 'EBBF72', 'F5D94E',
|
|
'B4CF7A', '7EC1AD', '3EA7C3', '0898D0', '3D87D4',
|
|
'6875D1', '9B60C5', '000000', 'E6E6E6'
|
|
];
|
|
|
|
$: orientation = (selectedBoard && settings.boardViews[selectedBoard.id]?.orientation) || 'horizontal';
|
|
$: visibleStacks = stacks.map((stack) => ({
|
|
...stack,
|
|
cards: (stack.cards ?? [])
|
|
.filter((card) => !card.archived && card.title.toLowerCase().includes(search.trim().toLowerCase()))
|
|
.sort((a, b) => a.order - b.order)
|
|
}));
|
|
|
|
onMount(async () => {
|
|
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
if (appWindow) {
|
|
try { currentVersion = await getVersion(); } catch { /* retain the packaged fallback */ }
|
|
}
|
|
if (import.meta.env.DEV && new URLSearchParams(location.search).has('demo')) {
|
|
selectedBoard = { id: 1, title: 'Homework', color: '8B7CF6' };
|
|
boards = [selectedBoard, { id: 2, title: 'Misc. To Do', color: 'E69A88' }, { id: 3, title: 'Test', color: '80C3A5' }];
|
|
stacks = [
|
|
{ id: 11, boardId: 1, title: 'Saturday', order: 1, cards: [
|
|
{ id: 101, stackId: 11, title: 'Preclinical Paperwork', description: 'Review the final checklist before submitting.', type: 'plain', owner: 'admin', order: 1, color: '80C3A5', done: '2026-07-19T12:00:00Z' },
|
|
{ id: 102, stackId: 11, title: '126L Mix Up Assignment', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
|
|
]},
|
|
{ id: 12, boardId: 1, title: 'Sunday', order: 2, cards: [
|
|
{ id: 103, stackId: 12, title: 'Pharm Med Assignment 1', type: 'plain', owner: 'admin', order: 1, color: '80C3A5', done: '2026-07-19T12:00:00Z' },
|
|
{ id: 104, stackId: 12, title: 'Laundry', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null, duedate: '2026-07-20T18:00:00Z' }
|
|
]},
|
|
{ id: 13, boardId: 1, title: 'Monday', order: 3, cards: [
|
|
{ id: 105, stackId: 13, title: 'Psych Discussion Board', description: 'Post the initial response and reply to two classmates.', type: 'plain', owner: 'admin', order: 1, color: 'E69A88', done: null },
|
|
{ id: 106, stackId: 13, title: 'Patho Concept Map', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
|
|
]}
|
|
];
|
|
status = 'Homework · 6 cards · Preview data';
|
|
return;
|
|
}
|
|
if (!serverUrl || !username) return;
|
|
try {
|
|
appPassword = await loadPassword(serverUrl, username);
|
|
rememberPassword = Boolean(appPassword);
|
|
if (appPassword) await connect(true);
|
|
} catch (error) {
|
|
status = friendlyError(error);
|
|
}
|
|
});
|
|
|
|
function friendlyError(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
async function checkForUpdates() {
|
|
if (!appWindow) {
|
|
updateState = 'error';
|
|
updateMessage = 'Update checks are available in the installed desktop application.';
|
|
return;
|
|
}
|
|
updateState = 'checking';
|
|
updateMessage = 'Checking Forgejo for a newer signed release…';
|
|
updateProgress = 0;
|
|
pendingUpdate = null;
|
|
try {
|
|
pendingUpdate = await check({ timeout: 30_000 });
|
|
if (pendingUpdate) {
|
|
updateState = 'available';
|
|
updateMessage = `Version ${pendingUpdate.version} is ready to install.${pendingUpdate.body ? ` ${pendingUpdate.body}` : ''}`;
|
|
} else {
|
|
updateState = 'current';
|
|
updateMessage = `Decky ${currentVersion} is up to date.`;
|
|
}
|
|
} catch (error) {
|
|
updateState = 'error';
|
|
updateMessage = `Update check failed: ${friendlyError(error)}`;
|
|
}
|
|
}
|
|
|
|
async function installUpdate() {
|
|
if (!pendingUpdate) return;
|
|
updateState = 'downloading';
|
|
updateMessage = `Downloading Decky ${pendingUpdate.version}…`;
|
|
let downloaded = 0;
|
|
let total = 0;
|
|
try {
|
|
await pendingUpdate.downloadAndInstall((event) => {
|
|
if (event.event === 'Started') {
|
|
total = event.data.contentLength ?? 0;
|
|
updateProgress = 0;
|
|
} else if (event.event === 'Progress') {
|
|
downloaded += event.data.chunkLength;
|
|
updateProgress = total > 0 ? Math.min(100, Math.round(downloaded / total * 100)) : 0;
|
|
} else if (event.event === 'Finished') {
|
|
updateState = 'installing';
|
|
updateProgress = 100;
|
|
updateMessage = 'Download verified. Installing and restarting Decky…';
|
|
}
|
|
});
|
|
await relaunch();
|
|
} catch (error) {
|
|
updateState = 'error';
|
|
updateMessage = `Update installation failed: ${friendlyError(error)}`;
|
|
}
|
|
}
|
|
|
|
async function connect(silent = false) {
|
|
busy = true;
|
|
status = 'Connecting to Nextcloud…';
|
|
try {
|
|
client = await DeckApiClient.connect({ serverUrl, username, appPassword });
|
|
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
|
|
settings = { ...settings, serverUrl, username };
|
|
saveSettings(settings);
|
|
if (rememberPassword) await savePassword(serverUrl, username, appPassword);
|
|
else await removePassword(serverUrl, username);
|
|
settingsOpen = false;
|
|
const preferred = boards.find((board) => board.id === settings.selectedBoardId) ?? boards[0];
|
|
if (preferred) await selectBoard(preferred);
|
|
else status = 'Connected, but no active Deck boards were found.';
|
|
} catch (error) {
|
|
client = null;
|
|
if (!silent) settingsOpen = true;
|
|
status = friendlyError(error);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function selectBoard(board: Board) {
|
|
if (!client) return;
|
|
selectedBoard = board;
|
|
settings = { ...settings, selectedBoardId: board.id };
|
|
saveSettings(settings);
|
|
await refreshBoard();
|
|
}
|
|
|
|
async function refreshBoard() {
|
|
if (!client || !selectedBoard) return;
|
|
busy = true;
|
|
status = `Loading ${selectedBoard.title}…`;
|
|
try {
|
|
stacks = (await client.getStacks(selectedBoard.id))
|
|
.filter((stack) => !stack.deletedAt)
|
|
.sort((a, b) => a.order - b.order);
|
|
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((card) => !card.archived).length ?? 0), 0);
|
|
status = `${selectedBoard.title} · ${count} card${count === 1 ? '' : 's'} · Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
|
|
} catch (error) {
|
|
status = friendlyError(error);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function setOrientation(value: BoardOrientation) {
|
|
if (!selectedBoard) return;
|
|
settings = {
|
|
...settings,
|
|
boardViews: { ...settings.boardViews, [selectedBoard.id]: { orientation: value } }
|
|
};
|
|
saveSettings(settings);
|
|
}
|
|
|
|
function openCreate(stack: Stack) {
|
|
colorPickerOpen = false;
|
|
editor = {
|
|
mode: 'create',
|
|
stackId: stack.id,
|
|
draft: { title: '', description: '', dueDate: '', color: settings.activeColor }
|
|
};
|
|
}
|
|
|
|
async function openEdit(card: Card) {
|
|
if (!client || !selectedBoard) return;
|
|
busy = true;
|
|
try {
|
|
const fullCard = await client.getCard(selectedBoard.id, card.stackId, card.id);
|
|
colorPickerOpen = false;
|
|
editor = {
|
|
mode: 'edit',
|
|
stackId: card.stackId,
|
|
card: fullCard,
|
|
draft: {
|
|
title: fullCard.title,
|
|
description: fullCard.description ?? '',
|
|
dueDate: toLocalDateTime(fullCard.duedate),
|
|
color: normalizeColor(fullCard.color)
|
|
}
|
|
};
|
|
} catch (error) {
|
|
status = friendlyError(error);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function toLocalDateTime(value?: string | null): string {
|
|
if (!value) return '';
|
|
const date = new Date(value);
|
|
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
|
return local.toISOString().slice(0, 16);
|
|
}
|
|
|
|
async function saveCard() {
|
|
if (!client || !selectedBoard || !editor || !editor.draft.title.trim()) return;
|
|
busy = true;
|
|
try {
|
|
if (editor.mode === 'create') {
|
|
await client.createCard(selectedBoard.id, editor.stackId, { ...editor.draft, title: editor.draft.title.trim() });
|
|
} else if (editor.card) {
|
|
await client.updateCard(
|
|
selectedBoard.id,
|
|
editor.stackId,
|
|
editor.card.id,
|
|
cardUpdateFrom(editor.card, {
|
|
title: editor.draft.title.trim(),
|
|
description: editor.draft.description.trim() || null,
|
|
duedate: editor.draft.dueDate ? new Date(editor.draft.dueDate).toISOString() : null,
|
|
color: normalizeColor(editor.draft.color)
|
|
})
|
|
);
|
|
}
|
|
editor = null;
|
|
colorPickerOpen = false;
|
|
await refreshBoard();
|
|
} catch (error) {
|
|
status = friendlyError(error);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function toggleDone(card: Card) {
|
|
if (!client || !selectedBoard) return;
|
|
busy = true;
|
|
try {
|
|
const fullCard = await client.getCard(selectedBoard.id, card.stackId, card.id);
|
|
const completing = !fullCard.done;
|
|
await client.updateCard(
|
|
selectedBoard.id,
|
|
fullCard.stackId,
|
|
fullCard.id,
|
|
cardUpdateFrom(fullCard, {
|
|
done: completing ? new Date().toISOString() : null,
|
|
color: completing ? settings.completedColor : settings.activeColor
|
|
})
|
|
);
|
|
await refreshBoard();
|
|
} catch (error) {
|
|
status = friendlyError(error);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function deleteCard(card: Card) {
|
|
if (!client || !selectedBoard || !confirm(`Delete “${card.title}”?`)) return;
|
|
busy = true;
|
|
try {
|
|
await client.deleteCard(selectedBoard.id, card.stackId, card.id);
|
|
await refreshBoard();
|
|
} catch (error) {
|
|
status = friendlyError(error);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
function saveAppearance() {
|
|
settings = {
|
|
...settings,
|
|
activeColor: normalizeColor(settings.activeColor),
|
|
completedColor: normalizeColor(settings.completedColor)
|
|
};
|
|
saveSettings(settings);
|
|
settingsOpen = false;
|
|
}
|
|
|
|
function disconnect() {
|
|
client = null;
|
|
boards = [];
|
|
stacks = [];
|
|
selectedBoard = null;
|
|
appPassword = '';
|
|
status = 'Disconnected from Nextcloud.';
|
|
}
|
|
|
|
function setSidebarCollapsed(collapsed: boolean) {
|
|
settings = { ...settings, sidebarCollapsed: collapsed };
|
|
saveSettings(settings);
|
|
}
|
|
|
|
async function runWindowAction(action: 'minimize' | 'maximize' | 'close') {
|
|
if (!appWindow) return;
|
|
try {
|
|
if (action === 'minimize') await appWindow.minimize();
|
|
else if (action === 'maximize') await appWindow.toggleMaximize();
|
|
else await appWindow.close();
|
|
} catch { /* The browser preview has no native window. */ }
|
|
}
|
|
|
|
async function dragWindow(event: MouseEvent) {
|
|
if (event.button !== 0 || !appWindow) return;
|
|
try { await appWindow.startDragging(); } catch { /* browser preview */ }
|
|
}
|
|
|
|
function cloneStacks(value: Stack[]): Stack[] {
|
|
return value.map((stack) => ({ ...stack, cards: stack.cards.map((card) => ({ ...card })) }));
|
|
}
|
|
|
|
function sameOrder(left: Stack[], right: Stack[]): boolean {
|
|
return left.map((stack) => `${stack.id}:${stack.cards.map((card) => card.id).join(',')}`).join('|')
|
|
=== right.map((stack) => `${stack.id}:${stack.cards.map((card) => card.id).join(',')}`).join('|');
|
|
}
|
|
|
|
function startPointerDrag(event: PointerEvent, kind: 'card' | 'stack', title: string, color?: string | null) {
|
|
if (event.button !== 0 || search || busy || (event.target as HTMLElement).closest('button')) return false;
|
|
event.preventDefault();
|
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
dragOriginStacks = cloneStacks(stacks);
|
|
dragKind = kind;
|
|
dragGhost = {
|
|
kind, title, color, x: event.clientX, y: event.clientY,
|
|
width: kind === 'card' ? rect.width : Math.min(rect.width, 330),
|
|
offsetX: Math.min(event.clientX - rect.left, kind === 'card' ? rect.width - 20 : 280),
|
|
offsetY: Math.min(event.clientY - rect.top, kind === 'card' ? rect.height - 10 : 24)
|
|
};
|
|
document.body.classList.add('sorting');
|
|
pointerMoveHandler = handlePointerMove;
|
|
pointerUpHandler = () => void commitPointerDrag();
|
|
pointerCancelHandler = cancelPointerDrag;
|
|
keyCancelHandler = (keyEvent) => {
|
|
if (keyEvent.key === 'Escape') cancelPointerDrag();
|
|
};
|
|
window.addEventListener('pointermove', pointerMoveHandler, { passive: false });
|
|
window.addEventListener('pointerup', pointerUpHandler, { once: true });
|
|
window.addEventListener('pointercancel', pointerCancelHandler, { once: true });
|
|
window.addEventListener('blur', pointerCancelHandler, { once: true });
|
|
window.addEventListener('keydown', keyCancelHandler);
|
|
return true;
|
|
}
|
|
|
|
function beginCardPointer(event: PointerEvent, card: Card) {
|
|
if (startPointerDrag(event, 'card', card.title, card.color)) {
|
|
draggedCard = { cardId: card.id, sourceStackId: card.stackId };
|
|
}
|
|
}
|
|
|
|
function previewCardOrder(stackId: number, index: number) {
|
|
if (!draggedCard) return;
|
|
const currentStack = stacks.find((stack) => stack.cards.some((card) => card.id === draggedCard?.cardId));
|
|
if (!currentStack) return;
|
|
const result = moveCardLocally(stacks, draggedCard.cardId, currentStack.id, stackId, index);
|
|
if (result && !sameOrder(stacks, result.stacks)) stacks = result.stacks;
|
|
}
|
|
|
|
function beginStackPointer(event: PointerEvent, stack: Stack) {
|
|
if (startPointerDrag(event, 'stack', stack.title)) draggedStackId = stack.id;
|
|
}
|
|
|
|
function previewStackOrder(targetStackId: number, after: boolean) {
|
|
if (draggedStackId === null) return;
|
|
const result = moveStackLocally(stacks, draggedStackId, targetStackId, after);
|
|
if (result && !sameOrder(stacks, result.stacks)) stacks = result.stacks;
|
|
}
|
|
|
|
function handlePointerMove(event: PointerEvent) {
|
|
if (!dragKind || !dragGhost) return;
|
|
event.preventDefault();
|
|
dragGhost = { ...dragGhost, x: event.clientX, y: event.clientY };
|
|
autoScrollBoard(event.clientX, event.clientY);
|
|
const target = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
|
|
if (dragKind === 'card' && draggedCard) {
|
|
const cardElement = target?.closest<HTMLElement>('[data-card-id]');
|
|
const listElement = target?.closest<HTMLElement>('[data-stack-cards]');
|
|
if (cardElement) {
|
|
const rect = cardElement.getBoundingClientRect();
|
|
const stackId = Number(cardElement.dataset.stackId);
|
|
const index = Number(cardElement.dataset.cardIndex);
|
|
const after = orientation === 'vertical'
|
|
? event.clientX > rect.left + rect.width / 2
|
|
: event.clientY > rect.top + rect.height / 2;
|
|
previewCardOrder(stackId, index + (after ? 1 : 0));
|
|
} else if (listElement) {
|
|
previewCardOrder(Number(listElement.dataset.stackCards), Number(listElement.dataset.cardCount));
|
|
}
|
|
} else if (dragKind === 'stack' && draggedStackId !== null) {
|
|
const stackElement = target?.closest<HTMLElement>('[data-stack-id]');
|
|
if (stackElement) {
|
|
const stackId = Number(stackElement.dataset.stackId);
|
|
if (stackId === draggedStackId) return;
|
|
const rect = stackElement.getBoundingClientRect();
|
|
const after = orientation === 'horizontal'
|
|
? event.clientX > rect.left + rect.width / 2
|
|
: event.clientY > rect.top + rect.height / 2;
|
|
previewStackOrder(stackId, after);
|
|
}
|
|
}
|
|
}
|
|
|
|
function autoScrollBoard(x: number, y: number) {
|
|
const canvas = document.querySelector<HTMLElement>('.board-canvas');
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const edge = 48;
|
|
const speedY = y < rect.top + edge ? -12 : y > rect.bottom - edge ? 12 : 0;
|
|
const speedX = x < rect.left + edge ? -12 : x > rect.right - edge ? 12 : 0;
|
|
if (speedX || speedY) canvas.scrollBy({ left: speedX, top: speedY });
|
|
}
|
|
|
|
async function commitPointerDrag() {
|
|
const origin = dragOriginStacks;
|
|
const card = draggedCard;
|
|
const stackId = draggedStackId;
|
|
const board = selectedBoard;
|
|
cleanupPointerDrag();
|
|
if (!origin || !board || sameOrder(origin, stacks) || !client) return;
|
|
busy = true;
|
|
try {
|
|
if (card) {
|
|
const targetStack = stacks.find((stack) => stack.cards.some((item) => item.id === card.cardId));
|
|
const targetIndex = targetStack?.cards.findIndex((item) => item.id === card.cardId) ?? -1;
|
|
if (!targetStack || targetIndex < 0) throw new Error('The card drop position could not be resolved.');
|
|
await client.reorderCard(board.id, card.sourceStackId, card.cardId, targetStack.id, targetIndex);
|
|
await refreshBoard();
|
|
} else if (stackId !== null) {
|
|
const targetIndex = stacks.findIndex((stack) => stack.id === stackId);
|
|
stacks = await client.reorderStack(board.id, stackId, targetIndex);
|
|
const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((item) => !item.archived).length ?? 0), 0);
|
|
status = `${board.title} · ${count} card${count === 1 ? '' : 's'} · Stack order saved`;
|
|
}
|
|
} catch (error) {
|
|
stacks = origin;
|
|
status = friendlyError(error);
|
|
await refreshBoard();
|
|
} finally { busy = false; }
|
|
}
|
|
|
|
function cancelPointerDrag() {
|
|
if (dragOriginStacks) stacks = dragOriginStacks;
|
|
cleanupPointerDrag();
|
|
}
|
|
|
|
function cleanupPointerDrag() {
|
|
if (pointerMoveHandler) window.removeEventListener('pointermove', pointerMoveHandler);
|
|
if (pointerUpHandler) window.removeEventListener('pointerup', pointerUpHandler);
|
|
if (pointerCancelHandler) {
|
|
window.removeEventListener('pointercancel', pointerCancelHandler);
|
|
window.removeEventListener('blur', pointerCancelHandler);
|
|
}
|
|
if (keyCancelHandler) window.removeEventListener('keydown', keyCancelHandler);
|
|
document.body.classList.remove('sorting');
|
|
pointerMoveHandler = null;
|
|
pointerUpHandler = null;
|
|
pointerCancelHandler = null;
|
|
keyCancelHandler = null;
|
|
dragKind = null;
|
|
draggedCard = null;
|
|
draggedStackId = null;
|
|
dragOriginStacks = null;
|
|
dragGhost = null;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>{selectedBoard ? `${selectedBoard.title} · Decky` : 'Decky'}</title></svelte:head>
|
|
|
|
<div class:sidebar-collapsed={settings.sidebarCollapsed} class="app-shell">
|
|
<header class="window-titlebar" role="presentation" on:mousedown={dragWindow} on:dblclick={() => runWindowAction('maximize')}>
|
|
<div class="window-title"><span class="window-mark"><Columns3 size={14} /></span><span>Decky</span></div>
|
|
<div class="window-controls" role="group" aria-label="Window controls">
|
|
<button aria-label="Minimize" title="Minimize" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('minimize')}><Minus size={15} /></button>
|
|
<button aria-label="Maximize" title="Maximize" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('maximize')}><Square size={12} /></button>
|
|
<button class="window-close" aria-label="Close" title="Close" on:mousedown|stopPropagation on:dblclick|stopPropagation on:click={() => runWindowAction('close')}><X size={15} /></button>
|
|
</div>
|
|
</header>
|
|
|
|
<aside class="sidebar">
|
|
<div class="sidebar-content">
|
|
{#if false}
|
|
<form class="connection-form" on:submit|preventDefault={() => connect()}>
|
|
<label>Server<input bind:value={serverUrl} autocomplete="url" placeholder="https://cloud.example.com" /></label>
|
|
<label>Username<input bind:value={username} autocomplete="username" /></label>
|
|
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
|
|
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
|
|
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : 'Connect'}</button>
|
|
</form>
|
|
{/if}
|
|
|
|
<div class="section-heading"><span>Boards</span><small>{boards.length}</small></div>
|
|
<nav class="board-list" aria-label="Deck boards">
|
|
{#each boards as board (board.id)}
|
|
<button class:active={selectedBoard?.id === board.id} on:click={() => selectBoard(board)}>
|
|
<span class="board-dot" style:background={cssColor(board.color)}></span>
|
|
<span>{board.title}</span>
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
</div>
|
|
<div class="sidebar-tools">
|
|
<div class="sidebar-layout" aria-label="Board orientation">
|
|
<button class:active={orientation === 'horizontal'} title="Horizontal layout" on:click={() => setOrientation('horizontal')}><Columns3 size={18} /><span>Columns</span></button>
|
|
<button class:active={orientation === 'vertical'} title="Vertical layout" on:click={() => setOrientation('vertical')}><LayoutList size={18} /><span>List</span></button>
|
|
</div>
|
|
<button class="sidebar-tool" title="Refresh" disabled={!selectedBoard || busy} on:click={refreshBoard}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
|
|
<button class="sidebar-tool" title="Settings" on:click={() => (settingsOpen = true)}><Settings size={18} /><span>Settings</span></button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="workspace">
|
|
{#if selectedBoard}
|
|
<div class="workspace-tools">
|
|
<div class="board-title">
|
|
<button class="sidebar-toggle" title={settings.sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'} on:click={() => setSidebarCollapsed(!settings.sidebarCollapsed)}>
|
|
{#if settings.sidebarCollapsed}<ChevronRight size={18} />{:else}<ChevronLeft size={18} />{/if}
|
|
</button>
|
|
{#if selectedBoard}<span class="board-dot" style:background={cssColor(selectedBoard.color)}></span><strong>{selectedBoard.title}</strong>{/if}
|
|
</div>
|
|
<label class="search-box"><Search size={17} /><input bind:value={search} placeholder="Find a card" /></label>
|
|
<span>{visibleStacks.length} stack{visibleStacks.length === 1 ? '' : 's'}</span>
|
|
</div>
|
|
|
|
<div class:vertical={orientation === 'vertical'} class="board-canvas">
|
|
{#each visibleStacks as stack (stack.id)}
|
|
<section
|
|
class:dragging={draggedStackId === stack.id}
|
|
class="stack"
|
|
role="group"
|
|
aria-label={`${stack.title} stack`}
|
|
data-stack-id={stack.id}
|
|
animate:flip={{ duration: reduceMotion ? 0 : 180 }}
|
|
>
|
|
<header class="stack-header" role="presentation" on:pointerdown={(event) => beginStackPointer(event, stack)}>
|
|
<div><h2>{stack.title}</h2><span>{stack.cards.length}</span></div>
|
|
<button class="icon-button subtle" title="Add a card" on:click={() => openCreate(stack)}><Plus size={18} /></button>
|
|
</header>
|
|
<div class:card-drag-target={dragKind === 'card'} class="card-list" role="list" data-stack-cards={stack.id} data-card-count={stack.cards.length}>
|
|
{#each stack.cards as card, cardIndex (card.id)}
|
|
<article
|
|
class:completed={Boolean(card.done)}
|
|
class:dragging={draggedCard?.cardId === card.id}
|
|
class="deck-card"
|
|
role="listitem"
|
|
data-card-id={card.id}
|
|
data-stack-id={stack.id}
|
|
data-card-index={cardIndex}
|
|
style:--card-color={cssColor(card.color)}
|
|
style:--card-text={textColor(card.color)}
|
|
on:pointerdown={(event) => beginCardPointer(event, card)}
|
|
animate:flip={{ duration: reduceMotion ? 0 : 170 }}
|
|
in:receiveCard={{ key: card.id }}
|
|
out:sendCard={{ key: card.id }}
|
|
>
|
|
<button class="complete-button" class:checked={Boolean(card.done)} title={card.done ? 'Mark active' : 'Mark complete'} on:click={() => toggleDone(card)}>
|
|
{#if card.done}<Check size={15} strokeWidth={3} />{/if}
|
|
</button>
|
|
<div class="card-body">
|
|
<h3>{card.title}</h3>
|
|
{#if card.description}<p>{card.description}</p>{/if}
|
|
{#if card.duedate}<span class="due">Due {new Date(card.duedate).toLocaleDateString([], { month: 'short', day: 'numeric' })}</span>{/if}
|
|
</div>
|
|
<div class="card-actions">
|
|
<button title="Edit card" on:click={() => openEdit(card)}><Pencil size={14} /></button>
|
|
<button title="Delete card" on:click={() => deleteCard(card)}><Trash2 size={14} /></button>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
{#if stack.cards.length === 0}<button class="empty-stack" on:click={() => openCreate(stack)}><Plus size={16} /> Add a card</button>{/if}
|
|
</div>
|
|
</section>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="empty-state">
|
|
<div class="empty-illustration"><Archive size={34} /><span></span><span></span></div>
|
|
<h1>Your boards, without the browser clutter.</h1>
|
|
<p>Connect Decky to Nextcloud, then choose a board from the sidebar.</p>
|
|
<button class="primary-button" on:click={() => (settingsOpen = true)}>Set up connection</button>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
|
|
<footer class="statusbar">
|
|
{#if busy}<LoaderCircle size={14} class="spinning" />{:else}<span class:connected={Boolean(client)} class="status-dot"></span>{/if}
|
|
<span>{status}</span>
|
|
</footer>
|
|
</div>
|
|
|
|
{#if dragGhost}
|
|
<div
|
|
class:card-drag-ghost={dragGhost.kind === 'card'}
|
|
class:stack-drag-ghost={dragGhost.kind === 'stack'}
|
|
class="drag-ghost"
|
|
style:left={`${dragGhost.x - dragGhost.offsetX}px`}
|
|
style:top={`${dragGhost.y - dragGhost.offsetY}px`}
|
|
style:width={`${dragGhost.width}px`}
|
|
style:--ghost-color={cssColor(dragGhost.color)}
|
|
>
|
|
{#if dragGhost.kind === 'card'}<span class="ghost-check"></span>{/if}
|
|
<strong>{dragGhost.title}</strong>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if editor}
|
|
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (editor = null, colorPickerOpen = false)}>
|
|
<form class="modal" on:submit|preventDefault={saveCard}>
|
|
<header><div><small>{editor.mode === 'create' ? 'NEW CARD' : 'EDIT CARD'}</small><h2>{editor.mode === 'create' ? 'Create a card' : editor.card?.title}</h2></div><button type="button" class="icon-button" on:click={() => (editor = null, colorPickerOpen = false)}><X size={20} /></button></header>
|
|
<label>Title<input bind:value={editor.draft.title} maxlength="255" /></label>
|
|
<label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details…"></textarea></label>
|
|
<div class="form-row">
|
|
<label>Due date<input type="datetime-local" bind:value={editor.draft.dueDate} /></label>
|
|
<label>Card color
|
|
<span class="color-control">
|
|
<button type="button" class="color-trigger" aria-expanded={colorPickerOpen} on:click={() => (colorPickerOpen = !colorPickerOpen)}>
|
|
<span class="color-preview" style:background={cssColor(editor.draft.color)}></span>
|
|
<span>#{normalizeColor(editor.draft.color)}</span>
|
|
</button>
|
|
{#if colorPickerOpen}
|
|
<span class="color-popover">
|
|
<span class="swatch-grid">
|
|
{#each colorPresets as color}
|
|
<button type="button" class:active={normalizeColor(editor.draft.color) === color} class="color-swatch" style:background={cssColor(color)} title={`#${color}`} on:click={() => { if (editor) editor.draft.color = color; colorPickerOpen = false; }}>
|
|
{#if normalizeColor(editor.draft.color) === color}<Check size={16} strokeWidth={3} />{/if}
|
|
</button>
|
|
{/each}
|
|
</span>
|
|
<span class="custom-color"><span>Custom color</span><input type="color" value={cssColor(editor.draft.color)} on:input={(event) => editor && (editor.draft.color = (event.currentTarget as HTMLInputElement).value.slice(1))} /></span>
|
|
<input class="color-hex" aria-label="Hex color" bind:value={editor.draft.color} maxlength="7" />
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<footer><button type="button" class="secondary-button" on:click={() => (editor = null, colorPickerOpen = false)}>Cancel</button><button class="primary-button" disabled={busy || !editor.draft.title.trim()}>{editor.mode === 'create' ? 'Create card' : 'Save changes'}</button></footer>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if settingsOpen}
|
|
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (settingsOpen = false)}>
|
|
<div class="modal settings-modal">
|
|
<header><div><small>SETTINGS</small><h2>Decky settings</h2></div><button type="button" class="icon-button" on:click={() => (settingsOpen = false)}><X size={20} /></button></header>
|
|
|
|
<section class="settings-section">
|
|
<div class="settings-heading"><div><h3>Nextcloud connection</h3><p>Use a revocable Nextcloud app password.</p></div><span class:connected={Boolean(client)} class="connection-badge"><span class="status-dot"></span>{client ? `Connected as ${username}` : 'Not connected'}</span></div>
|
|
<form class="settings-connection" on:submit|preventDefault={() => connect()}>
|
|
<label>Server<input bind:value={serverUrl} autocomplete="url" placeholder="https://cloud.example.com" /></label>
|
|
<label>Username<input bind:value={username} autocomplete="username" /></label>
|
|
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
|
|
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
|
|
<div class="settings-actions">
|
|
{#if client}<button type="button" class="secondary-button" on:click={disconnect}>Disconnect</button>{/if}
|
|
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting…' : client ? 'Reconnect' : 'Connect'}</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="settings-section update-section">
|
|
<div class="settings-heading">
|
|
<div><h3>Decky updates</h3><p>Installer updates are signed and delivered through your Forgejo instance.</p></div>
|
|
<span class="version-badge">Version {currentVersion}</span>
|
|
</div>
|
|
<div class:error={updateState === 'error'} class="update-status" aria-live="polite">
|
|
<div>
|
|
<strong>{updateState === 'available' ? 'Update available' : updateState === 'current' ? 'Up to date' : updateState === 'error' ? 'Update unavailable' : 'Release channel: stable'}</strong>
|
|
<p>{updateMessage}</p>
|
|
</div>
|
|
{#if updateState === 'downloading' || updateState === 'installing'}
|
|
<span class="update-progress" aria-label={`Update progress ${updateProgress}%`}><span style:width={`${updateProgress}%`}></span></span>
|
|
{/if}
|
|
</div>
|
|
<div class="settings-actions update-actions">
|
|
{#if updateState === 'available'}
|
|
<button type="button" class="primary-button" on:click={installUpdate}>Download and install {pendingUpdate?.version}</button>
|
|
{:else}
|
|
<button type="button" class="secondary-button" disabled={updateState === 'checking' || updateState === 'downloading' || updateState === 'installing'} on:click={checkForUpdates}>
|
|
{updateState === 'checking' ? 'Checking…' : 'Check for updates'}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<form class="settings-section" on:submit|preventDefault={saveAppearance}>
|
|
<div class="settings-heading"><div><h3>Card colors</h3><p>Completing a card applies the completed color; unchecking restores the active color.</p></div></div>
|
|
<div class="color-settings">
|
|
<label>Active cards<span class="color-field"><input type="color" value={cssColor(settings.activeColor)} on:input={(event) => (settings.activeColor = (event.currentTarget as HTMLInputElement).value.slice(1))} /><input bind:value={settings.activeColor} /></span></label>
|
|
<label>Completed cards<span class="color-field"><input type="color" value={cssColor(settings.completedColor)} on:input={(event) => (settings.completedColor = (event.currentTarget as HTMLInputElement).value.slice(1))} /><input bind:value={settings.completedColor} /></span></label>
|
|
</div>
|
|
<footer><button type="button" class="secondary-button" on:click={() => (settingsOpen = false)}>Cancel</button><button class="primary-button">Save settings</button></footer>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{/if}
|