Add in-app reduce motion setting under Settings > Appearance so users can disable animations without changing their OS preference. Applies a .reduce-motion CSS class to kill all CSS transitions/animations and wraps the app in MotionConfig to globally disable Framer Motion springs, layout animations, and enter/exit transitions. Setting persists to disk. Also removes leftover default Square*.png icons and bumps version to 1.0.1.
219 lines
6.9 KiB
TypeScript
219 lines
6.9 KiB
TypeScript
import { create } from "zustand";
|
|
import type { AppSettings, BoardSortOrder } from "@/types/settings";
|
|
import type { BoardMeta, ColumnWidth } from "@/types/board";
|
|
import { loadSettings, saveSettings, listBoards, ensureDataDirs, loadBoard } from "@/lib/storage";
|
|
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
|
|
|
|
export type View = { type: "board-list" } | { type: "board"; boardId: string };
|
|
|
|
interface AppState {
|
|
settings: AppSettings;
|
|
boards: BoardMeta[];
|
|
view: View;
|
|
initialized: boolean;
|
|
|
|
init: () => Promise<void>;
|
|
setTheme: (theme: AppSettings["theme"]) => void;
|
|
setAccentColor: (hue: string) => void;
|
|
setUiZoom: (zoom: number) => void;
|
|
setDensity: (density: AppSettings["density"]) => void;
|
|
setDefaultColumnWidth: (width: ColumnWidth) => void;
|
|
setView: (view: View) => void;
|
|
refreshBoards: () => Promise<void>;
|
|
addRecentBoard: (boardId: string) => void;
|
|
setReduceMotion: (reduceMotion: boolean) => void;
|
|
setBoardSortOrder: (order: BoardSortOrder) => void;
|
|
setBoardManualOrder: (ids: string[]) => void;
|
|
getSortedBoards: () => BoardMeta[];
|
|
}
|
|
|
|
function applyTheme(theme: AppSettings["theme"]): void {
|
|
const root = document.documentElement;
|
|
if (theme === "system") {
|
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
root.classList.toggle("dark", prefersDark);
|
|
} else {
|
|
root.classList.toggle("dark", theme === "dark");
|
|
}
|
|
}
|
|
|
|
function applyReduceMotion(on: boolean): void {
|
|
document.documentElement.classList.toggle("reduce-motion", on);
|
|
}
|
|
|
|
function applyAppearance(settings: AppSettings): void {
|
|
const root = document.documentElement;
|
|
root.style.fontSize = `${settings.uiZoom * 16}px`;
|
|
const hue = settings.accentColor;
|
|
const isDark = root.classList.contains("dark");
|
|
const lightness = isDark ? "60%" : "55%";
|
|
root.style.setProperty("--pylon-accent", `oklch(${lightness} 0.12 ${hue})`);
|
|
const densityMap = { compact: "0.75", comfortable: "1", spacious: "1.25" };
|
|
root.style.setProperty("--density-factor", densityMap[settings.density]);
|
|
}
|
|
|
|
function updateAndSave(
|
|
get: () => AppState,
|
|
set: (partial: Partial<AppState>) => void,
|
|
patch: Partial<AppSettings>
|
|
): void {
|
|
const settings = { ...get().settings, ...patch };
|
|
set({ settings });
|
|
saveSettings(settings);
|
|
}
|
|
|
|
export const useAppStore = create<AppState>((set, get) => ({
|
|
settings: {
|
|
theme: "system",
|
|
dataDirectory: null,
|
|
recentBoardIds: [],
|
|
accentColor: "160",
|
|
uiZoom: 1,
|
|
density: "comfortable",
|
|
defaultColumnWidth: "standard",
|
|
windowState: null,
|
|
boardSortOrder: "updated",
|
|
boardManualOrder: [],
|
|
lastNotificationCheck: null,
|
|
reduceMotion: false,
|
|
},
|
|
boards: [],
|
|
view: { type: "board-list" },
|
|
initialized: false,
|
|
|
|
init: async () => {
|
|
await ensureDataDirs();
|
|
const settings = await loadSettings();
|
|
const boards = await listBoards();
|
|
set({ settings, boards, initialized: true });
|
|
applyTheme(settings.theme);
|
|
applyAppearance(settings);
|
|
applyReduceMotion(settings.reduceMotion);
|
|
|
|
// Due date notifications (once per hour)
|
|
const lastCheck = settings.lastNotificationCheck;
|
|
const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
if (!lastCheck || lastCheck < hourAgo) {
|
|
try {
|
|
let granted = await isPermissionGranted();
|
|
if (!granted) {
|
|
const perm = await requestPermission();
|
|
granted = perm === "granted";
|
|
}
|
|
if (granted) {
|
|
let dueToday = 0;
|
|
let overdue = 0;
|
|
const today = new Date();
|
|
const todayStr = today.toDateString();
|
|
|
|
for (const meta of boards) {
|
|
try {
|
|
const board = await loadBoard(meta.id);
|
|
for (const card of Object.values(board.cards)) {
|
|
if (!card.dueDate) continue;
|
|
const due = new Date(card.dueDate);
|
|
if (due.toDateString() === todayStr) dueToday++;
|
|
else if (due < today) overdue++;
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
if (dueToday > 0) {
|
|
sendNotification({ title: "OpenPylon", body: `You have ${dueToday} card${dueToday > 1 ? "s" : ""} due today` });
|
|
}
|
|
if (overdue > 0) {
|
|
sendNotification({ title: "OpenPylon", body: `You have ${overdue} overdue card${overdue > 1 ? "s" : ""}` });
|
|
}
|
|
}
|
|
updateAndSave(get, set, { lastNotificationCheck: new Date().toISOString() });
|
|
} catch { /* notification plugin not available */ }
|
|
}
|
|
},
|
|
|
|
setTheme: (theme) => {
|
|
updateAndSave(get, set, { theme });
|
|
applyTheme(theme);
|
|
applyAppearance({ ...get().settings, theme });
|
|
},
|
|
|
|
setAccentColor: (accentColor) => {
|
|
updateAndSave(get, set, { accentColor });
|
|
applyAppearance(get().settings);
|
|
},
|
|
|
|
setUiZoom: (uiZoom) => {
|
|
updateAndSave(get, set, { uiZoom });
|
|
applyAppearance(get().settings);
|
|
},
|
|
|
|
setDensity: (density) => {
|
|
updateAndSave(get, set, { density });
|
|
applyAppearance(get().settings);
|
|
},
|
|
|
|
setDefaultColumnWidth: (defaultColumnWidth) => {
|
|
updateAndSave(get, set, { defaultColumnWidth });
|
|
},
|
|
|
|
setReduceMotion: (reduceMotion) => {
|
|
updateAndSave(get, set, { reduceMotion });
|
|
applyReduceMotion(reduceMotion);
|
|
},
|
|
|
|
setView: (view) => set({ view }),
|
|
|
|
refreshBoards: async () => {
|
|
const boards = await listBoards();
|
|
set({ boards });
|
|
},
|
|
|
|
addRecentBoard: (boardId) => {
|
|
const settings = get().settings;
|
|
const recent = [
|
|
boardId,
|
|
...settings.recentBoardIds.filter((id) => id !== boardId),
|
|
].slice(0, 10);
|
|
updateAndSave(get, set, { recentBoardIds: recent });
|
|
},
|
|
|
|
setBoardSortOrder: (boardSortOrder) => {
|
|
// When switching to manual for the first time, snapshot current order
|
|
if (boardSortOrder === "manual" && get().settings.boardManualOrder.length === 0) {
|
|
const currentSorted = get().getSortedBoards();
|
|
updateAndSave(get, set, {
|
|
boardSortOrder,
|
|
boardManualOrder: currentSorted.map((b) => b.id),
|
|
});
|
|
} else {
|
|
updateAndSave(get, set, { boardSortOrder });
|
|
}
|
|
},
|
|
|
|
setBoardManualOrder: (boardManualOrder) => {
|
|
updateAndSave(get, set, { boardManualOrder });
|
|
},
|
|
|
|
getSortedBoards: () => {
|
|
const { boards, settings } = get();
|
|
const order = settings.boardSortOrder;
|
|
|
|
if (order === "manual") {
|
|
const manualOrder = settings.boardManualOrder;
|
|
const orderMap = new Map(manualOrder.map((id, i) => [id, i]));
|
|
return [...boards].sort((a, b) => {
|
|
const ai = orderMap.get(a.id) ?? Infinity;
|
|
const bi = orderMap.get(b.id) ?? Infinity;
|
|
return ai - bi;
|
|
});
|
|
}
|
|
if (order === "title") {
|
|
return [...boards].sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
if (order === "created") {
|
|
return [...boards].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
}
|
|
// "updated" — default, already sorted from listBoards
|
|
return boards;
|
|
},
|
|
}));
|