Initial commit of version 0.2.0
This commit is contained in:
parent
86fc06b877
commit
dfb04462f3
108 changed files with 16167 additions and 80 deletions
762
app/src/App.svelte
Normal file
762
app/src/App.svelte
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
<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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue