diff --git a/package-lock.json b/package-lock.json index d421d022..3f4c12dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", + "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/lexend": "^5.2.11", "@fontsource-variable/manrope": "^5.2.8", "@fontsource-variable/nunito": "^5.2.7", @@ -803,6 +805,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz", + "integrity": "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource-variable/inter": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", @@ -812,6 +823,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource-variable/lexend": { "version": "5.2.11", "resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz", diff --git a/package.json b/package.json index 946f3946..674420ab 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", + "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/lexend": "^5.2.11", "@fontsource-variable/manrope": "^5.2.8", "@fontsource-variable/nunito": "^5.2.7", @@ -46,8 +48,8 @@ "devDependencies": { "@tauri-apps/cli": "^2", "@types/md5": "^2.3.5", - "@types/papaparse": "^5.5.2", "@types/node": "^25.3.5", + "@types/papaparse": "^5.5.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.2", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23c62eff..55008981 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,8 +8,8 @@ mod discord; mod taskbar_win; use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{ menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem}, @@ -28,6 +28,13 @@ const MAX_DL_CONCURRENCY: usize = 4; /// Shared semaphore that caps simultaneous `download_track_offline` executions. type DownloadSemaphore = Arc; +/// Per-job cancellation flags for `sync_batch_to_device`. +/// Each running sync registers an `Arc` here; `cancel_device_sync` flips it. +fn sync_cancel_flags() -> &'static Mutex>> { + static FLAGS: OnceLock>>> = OnceLock::new(); + FLAGS.get_or_init(|| Mutex::new(HashMap::new())) +} + /// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed. /// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms. type TrayState = Mutex>; @@ -1409,6 +1416,26 @@ fn get_removable_drives() -> Vec { .collect() } +/// Writes a `psysonic-sync.json` manifest to the root of the target directory. +/// The file records which sources (albums/playlists/artists) are synced to this +/// device so that another machine can pick them up without relying on localStorage. +#[tauri::command] +fn write_device_manifest(dest_dir: String, sources: serde_json::Value) -> Result<(), String> { + let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); + let payload = serde_json::json!({ "version": 1, "sources": sources }); + let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| e.to_string()) +} + +/// Reads `psysonic-sync.json` from the target directory. +/// Returns the parsed JSON value, or null if the file doesn't exist. +#[tauri::command] +fn read_device_manifest(dest_dir: String) -> Option { + let path = std::path::Path::new(&dest_dir).join("psysonic-sync.json"); + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&content).ok() +} + /// Checks whether `path` sits on top of an active mount point (i.e. not the root /// filesystem). This prevents accidentally writing to `/media/usb` after the /// USB drive has been unmounted — at that point the path would fall through to `/` @@ -1420,14 +1447,22 @@ fn is_path_on_mounted_volume(path: &std::path::Path) -> bool { Ok(c) => c, Err(_) => return false, // path doesn't exist or isn't accessible }; - let canonical_str = canonical.to_string_lossy(); + // On Windows, canonicalize() prepends "\\?\" (extended-path prefix). + // Strip it so that "\\?\E:\Music" compares correctly against mount point "E:\". + let canonical_raw = canonical.to_string_lossy().into_owned(); + #[cfg(target_os = "windows")] + let canonical_str = canonical_raw.strip_prefix(r"\\?\").unwrap_or(&canonical_raw).to_string(); + #[cfg(not(target_os = "windows"))] + let canonical_str = canonical_raw; // Find the longest mount-point prefix that matches this path. // Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback. let mut best_len: usize = 0; for disk in disks.list() { let mp = disk.mount_point().to_string_lossy().to_string(); - // Skip root mount points - if mp == "/" || (mp.len() == 3 && mp.ends_with(":\\")) { + // Skip root mount points (Linux "/" and non-removable Windows drive roots like "C:\"). + // Do NOT skip removable Windows drives (e.g. "E:\") — those are valid sync targets. + let is_windows_root = mp.len() == 3 && mp.ends_with(":\\") && !disk.is_removable(); + if mp == "/" || is_windows_root { continue; } if canonical_str.starts_with(&mp) && mp.len() > best_len { @@ -1491,13 +1526,18 @@ fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String { .map(|y| y.to_string()) .unwrap_or_default(); - template + let result = template .replace("{artist}", &sanitize_path_component(&track.artist)) .replace("{album}", &sanitize_path_component(&track.album)) .replace("{title}", &sanitize_path_component(&track.title)) .replace("{track_number}", &track_number) .replace("{disc_number}", &disc_number) - .replace("{year}", &year) + .replace("{year}", &year); + // Normalize to the OS path separator so compute_sync_paths and list_device_dir_files + // produce identical strings for Set comparison. + #[cfg(target_os = "windows")] + let result = result.replace('/', "\\"); + result } /// Downloads a single track to a USB/SD device using the configured filename template. @@ -1853,6 +1893,17 @@ async fn calculate_sync_payload( }) } +/// Signals a running `sync_batch_to_device` job to stop after its current tracks finish. +#[tauri::command] +fn cancel_device_sync(job_id: String, app: tauri::AppHandle) { + if let Ok(mut flags) = sync_cancel_flags().lock() { + if let Some(flag) = flags.get(&job_id) { + flag.store(true, Ordering::Relaxed); + } + } + let _ = app.emit("device:sync:cancelled", serde_json::json!({ "jobId": job_id })); +} + /// Downloads a batch of tracks to a USB/SD device with controlled concurrency. /// At most 2 parallel writes run simultaneously to prevent I/O choking on USB. /// Emits throttled `device:sync:progress` events (max once per 500ms) and a @@ -1896,6 +1947,12 @@ async fn sync_batch_to_device( } } + // Register a cancellation flag for this job. + let cancel_flag = Arc::new(AtomicBool::new(false)); + if let Ok(mut flags) = sync_cancel_flags().lock() { + flags.insert(job_id.clone(), cancel_flag.clone()); + } + // Shared reqwest client — reused across all downloads. let client = reqwest::Client::builder() .timeout(Duration::from_secs(300)) @@ -1927,10 +1984,14 @@ async fn sync_batch_to_device( let s = skipped.clone(); let f = failed.clone(); let le = last_emit.clone(); + let cancel = cancel_flag.clone(); handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.expect("semaphore closed"); + // Bail out if cancelled while waiting in the semaphore queue. + if cancel.load(Ordering::Relaxed) { return; } + let relative = apply_device_sync_template(&tmpl, &track); let file_name = format!("{}.{}", relative, track.suffix); let dest_path = std::path::Path::new(&dest).join(&file_name); @@ -2024,6 +2085,12 @@ async fn sync_batch_to_device( let _ = handle.await; } + // Clean up the cancellation flag. + let was_cancelled = cancel_flag.load(Ordering::Relaxed); + if let Ok(mut flags) = sync_cancel_flags().lock() { + flags.remove(&job_id); + } + let result = SyncBatchResult { done: done.load(Ordering::Relaxed), skipped: skipped.load(Ordering::Relaxed), @@ -2037,6 +2104,7 @@ async fn sync_batch_to_device( "skipped": result.skipped, "failed": result.failed, "total": total, + "cancelled": was_cancelled, })); Ok(result) @@ -2507,11 +2575,14 @@ pub fn run() { purge_hot_cache, sync_track_to_device, sync_batch_to_device, + cancel_device_sync, compute_sync_paths, list_device_dir_files, delete_device_file, delete_device_files, get_removable_drives, + write_device_manifest, + read_device_manifest, toggle_tray_icon, check_dir_accessible, download_zip, diff --git a/src/locales/de.ts b/src/locales/de.ts index 1ebb53db..1d31116d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1085,6 +1085,7 @@ export const deTranslation = { deleteFromDevice: 'Vom Gerät löschen ({{count}})', confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?', deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.', + manifestImported: '{{count}} Quelle(n) aus Geräte-Manifest importiert.', selectedSources: 'Ausgewählte Quellen', noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.', clearAll: 'Alle entfernen', @@ -1104,6 +1105,8 @@ export const deTranslation = { fetchError: 'Fehler beim Laden der Tracks vom Server.', syncComplete: 'Übertragung abgeschlossen.', dismiss: 'Schließen', + cancelSync: 'Abbrechen', + syncCancelled: 'Sync abgebrochen ({{done}} / {{total}} übertragen).', colStatus: 'Status', statusSynced: 'Synchronisiert', statusPending: 'Ausstehend', diff --git a/src/locales/en.ts b/src/locales/en.ts index 29bdffc4..d423862c 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1088,6 +1088,7 @@ export const enTranslation = { deleteFromDevice: 'Mark for deletion ({{count}})', confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?', deleteComplete: '{{count}} item(s) removed from device.', + manifestImported: 'Imported {{count}} source(s) from device manifest.', selectedSources: 'Selected Sources', noSourcesSelected: 'No sources selected. Click items in the browser to add them.', clearAll: 'Clear all', @@ -1107,6 +1108,8 @@ export const enTranslation = { fetchError: 'Failed to fetch tracks from server.', syncComplete: 'Sync complete.', dismiss: 'Dismiss', + cancelSync: 'Cancel', + syncCancelled: 'Sync cancelled ({{done}} / {{total}} transferred).', statusSynced: 'Synced', statusPending: 'Pending', statusDeletion: 'Deletion', diff --git a/src/locales/es.ts b/src/locales/es.ts index 6f31c469..af16f663 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1103,6 +1103,8 @@ export const esTranslation = { fetchError: 'Error al obtener pistas del servidor.', syncComplete: 'Sincronización completada.', dismiss: 'Cerrar', + cancelSync: 'Cancelar', + syncCancelled: 'Sincronización cancelada ({{done}} / {{total}} transferidos).', colName: 'Nombre', colType: 'Tipo', colStatus: 'Estado', @@ -1112,6 +1114,7 @@ export const esTranslation = { deleteFromDevice: 'Marcar para eliminar ({{count}})', confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?', deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.', + manifestImported: '{{count}} fuente(s) importada(s) desde el manifiesto del dispositivo.', statusSynced: 'Sincronizado', statusPending: 'Pendiente', statusDeletion: 'Eliminación', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 82485f9c..9eb5d722 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1098,6 +1098,8 @@ export const frTranslation = { fetchError: 'Échec du chargement des pistes depuis le serveur.', syncComplete: 'Synchronisation terminée.', dismiss: 'Fermer', + cancelSync: 'Annuler', + syncCancelled: 'Synchronisation annulée ({{done}} / {{total}} transférés).', colName: 'Nom', colType: 'Type', colStatus: 'Statut', @@ -1107,6 +1109,7 @@ export const frTranslation = { deleteFromDevice: 'Marquer pour suppression ({{count}})', confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?', deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.', + manifestImported: '{{count}} source(s) importée(s) depuis le manifeste du périphérique.', statusSynced: 'Synchronisé', statusPending: 'En attente', statusDeletion: 'Suppression', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 44bfada7..9f73adea 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1097,6 +1097,8 @@ export const nbTranslation = { fetchError: 'Kunne ikke hente spor fra serveren.', syncComplete: 'Synkronisering fullført.', dismiss: 'Lukk', + cancelSync: 'Avbryt', + syncCancelled: 'Synkronisering avbrutt ({{done}} / {{total}} overført).', colName: 'Navn', colType: 'Type', colStatus: 'Status', @@ -1106,6 +1108,7 @@ export const nbTranslation = { deleteFromDevice: 'Merk for sletting ({{count}})', confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?', deleteComplete: '{{count}} element(er) fjernet fra enheten.', + manifestImported: '{{count}} kilde(r) importert fra enhetsmanifest.', statusSynced: 'Synkronisert', statusPending: 'Venter', statusDeletion: 'Sletting', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 0863a858..f0cfc30f 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1097,6 +1097,8 @@ export const nlTranslation = { fetchError: 'Ophalen van nummers van de server mislukt.', syncComplete: 'Synchronisatie voltooid.', dismiss: 'Sluiten', + cancelSync: 'Annuleren', + syncCancelled: 'Synchronisatie geannuleerd ({{done}} / {{total}} overgebracht).', colName: 'Naam', colType: 'Type', colStatus: 'Status', @@ -1106,6 +1108,7 @@ export const nlTranslation = { deleteFromDevice: 'Markeren voor verwijdering ({{count}})', confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?', deleteComplete: '{{count}} item(s) van het apparaat verwijderd.', + manifestImported: '{{count}} bron(nen) geïmporteerd uit apparaatmanifest.', statusSynced: 'Gesynchroniseerd', statusPending: 'In behandeling', statusDeletion: 'Verwijdering', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 6c0be378..704fa2c8 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1156,6 +1156,8 @@ export const ruTranslation = { fetchError: 'Ошибка загрузки треков с сервера.', syncComplete: 'Синхронизация завершена.', dismiss: 'Закрыть', + cancelSync: 'Отмена', + syncCancelled: 'Синхронизация отменена ({{done}} / {{total}} перенесено).', colName: 'Название', colType: 'Тип', colStatus: 'Статус', @@ -1165,6 +1167,7 @@ export const ruTranslation = { deleteFromDevice: 'Пометить для удаления ({{count}})', confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?', deleteComplete: '{{count}} запись(ей) удалено с устройства.', + manifestImported: 'Импортировано {{count}} источник(ов) из манифеста устройства.', statusSynced: 'Синхронизировано', statusPending: 'Ожидает', statusDeletion: 'Удаление', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index bd5797ac..1fba539e 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1091,6 +1091,8 @@ export const zhTranslation = { fetchError: '从服务器加载曲目失败。', syncComplete: '同步完成。', dismiss: '关闭', + cancelSync: '取消', + syncCancelled: '同步已取消(已传输 {{done}} / {{total}})。', colName: '名称', colType: '类型', colStatus: '状态', @@ -1100,6 +1102,7 @@ export const zhTranslation = { deleteFromDevice: '标记为删除({{count}})', confirmDelete: '从设备删除 {{count}} 个项目:{{names}}?', deleteComplete: '已从设备删除 {{count}} 个项目。', + manifestImported: '已从设备清单导入 {{count}} 个来源。', statusSynced: '已同步', statusPending: '待处理', statusDeletion: '删除中', diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index 98dd0ae3..e44352b7 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -251,20 +251,27 @@ export default function DeviceSync() { }); const unlistenComplete = listen<{ - jobId: string; done: number; skipped: number; failed: number; total: number; + jobId: string; done: number; skipped: number; failed: number; total: number; cancelled?: boolean; }>('device:sync:complete', ({ payload }) => { const current = jobStore(); if (current.jobId && payload.jobId === current.jobId) { - useDeviceSyncJobStore.getState().complete( - payload.done, payload.skipped, payload.failed - ); - showToast( - t('deviceSync.syncResult', { - done: payload.done, skipped: payload.skipped, total: payload.total - }), - 5000, 'info' - ); - // Re-scan the device after sync completes + if (payload.cancelled) { + useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); + // status is already 'cancelled' from the button click; complete() would overwrite it — restore it + useDeviceSyncJobStore.getState().cancel(); + } else { + useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); + showToast( + t('deviceSync.syncResult', { + done: payload.done, skipped: payload.skipped, total: payload.total + }), + 5000, 'info' + ); + // Write manifest so another machine can read the synced sources from the stick + const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); + if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); + } + // Re-scan the device after sync completes (cancelled or not) scanDevice(); } }); @@ -344,7 +351,21 @@ export default function DeviceSync() { const handleChooseFolder = async () => { const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') }); if (sel) { - setTargetDir(sel as string); + const dir = sel as string; + setTargetDir(dir); + // If the device has a psysonic-sync.json and localStorage has no sources yet, + // auto-import so the list is populated when switching machines. + if (useDeviceSyncStore.getState().sources.length === 0) { + try { + const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>( + 'read_device_manifest', { destDir: dir } + ); + if (manifest?.sources?.length) { + manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); + showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); + } + } catch { /* no manifest, that's fine */ } + } // Trigger a device scan after folder change setTimeout(() => scanDevice(), 100); } @@ -401,6 +422,9 @@ export default function DeviceSync() { await invoke('delete_device_files', { paths: allPaths }); removeSources(deletionSources.map(s => s.id)); + // Update manifest so it stays in sync after deletions + const remainingSources = useDeviceSyncStore.getState().sources; + if (targetDir) invoke('write_device_manifest', { destDir: targetDir, sources: remainingSources }).catch(() => {}); showToast( t('deviceSync.deleteComplete', { count: deletionSources.length }), 3000, 'info' @@ -815,6 +839,29 @@ export default function DeviceSync() { {t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })} {jobFail > 0 && {jobFail}} + + + )} + + {jobStatus === 'cancelled' && ( +
+ + + {t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })} + +
)} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 12f35c20..fb27a3a7 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -406,6 +406,7 @@ export default function Settings() { const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); const [contributorsOpen, setContributorsOpen] = useState(false); + const [fontPickerOpen, setFontPickerOpen] = useState(false); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } @@ -1712,31 +1713,58 @@ export default function Settings() {

{t('settings.font')}

-
- {( - [ - { id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" }, - { id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" }, - { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" }, - { id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" }, - { id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" }, - { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" }, - { id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" }, - { id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" }, - { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" }, - { id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" }, - ] as { id: FontId; label: string; stack: string }[] - ).map(f => ( - - ))} -
+ + {fontPickerOpen && ( +
+ {( + [ + { id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" }, + { id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" }, + { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" }, + { id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" }, + { id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" }, + { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" }, + { id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" }, + { id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" }, + { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" }, + { id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" }, + { id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" }, + { id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" }, + ] as { id: FontId; label: string; stack: string }[] + ).map(f => ( + + ))} +
+ )}
diff --git a/src/store/deviceSyncJobStore.ts b/src/store/deviceSyncJobStore.ts index 3a9638b2..9b77dcfe 100644 --- a/src/store/deviceSyncJobStore.ts +++ b/src/store/deviceSyncJobStore.ts @@ -11,6 +11,7 @@ export interface DeviceSyncJobState { startSync: (jobId: string, total: number) => void; updateProgress: (done: number, skipped: number, failed: number) => void; complete: (done: number, skipped: number, failed: number) => void; + cancel: () => void; reset: () => void; } @@ -31,6 +32,9 @@ export const useDeviceSyncJobStore = create()((set) => ({ complete: (done, skipped, failed) => set({ done, skipped, failed, status: 'done' }), + cancel: () => + set({ status: 'cancelled' }), + reset: () => set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }), })); diff --git a/src/store/fontStore.ts b/src/store/fontStore.ts index 381a07ed..dde9e309 100644 --- a/src/store/fontStore.ts +++ b/src/store/fontStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend'; +export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'space-grotesk' | 'figtree' | 'manrope' | 'plus-jakarta-sans' | 'lexend' | 'geist' | 'jetbrains-mono'; interface FontState { font: FontId; diff --git a/src/styles/theme.css b/src/styles/theme.css index b7ff2979..354cf082 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -19,6 +19,9 @@ @import '@fontsource-variable/plus-jakarta-sans'; @import '@fontsource-variable/plus-jakarta-sans/wght-italic.css'; @import '@fontsource-variable/lexend'; +@import '@fontsource-variable/geist'; +@import '@fontsource-variable/jetbrains-mono'; +@import '@fontsource-variable/jetbrains-mono/wght-italic.css'; /* ─── Catppuccin Mocha Variables ─── */ :root, @@ -6399,6 +6402,16 @@ input[type="range"]:hover::-webkit-slider-thumb { --font-display: 'Lexend Variable', sans-serif; } +[data-font='geist'] { + --font-sans: 'Geist Variable', system-ui, sans-serif; + --font-display: 'Geist Variable', sans-serif; +} + +[data-font='jetbrains-mono'] { + --font-sans: 'JetBrains Mono Variable', monospace; + --font-display: 'JetBrains Mono Variable', monospace; +} + /* ─── Equalizer bars animation ─── */ .eq-bar { display: inline-block;