mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(device-sync): manifest, cancel, font picker + sync status fix
- Write psysonic-sync.json to device after sync/deletions; auto-import on folder select when localStorage is empty (cross-platform handoff) - Add cancel button during active sync: AtomicBool flag per job, tasks bail after acquiring semaphore, cancelled state in UI - Fix sync status staying "pending": normalize template path separators to OS separator (Windows: '/' → '\') so compute_sync_paths and list_device_dir_files produce matching strings for Set comparison - Add Geist and JetBrains Mono as variable fonts; font picker collapsible - Fix device mount detection on Windows: removable drive letters (E:\) were incorrectly skipped alongside system roots; strip \?\ prefix from canonicalized paths before mount-point comparison Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+20
@@ -10,7 +10,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||||
"@fontsource-variable/figtree": "^5.2.10",
|
"@fontsource-variable/figtree": "^5.2.10",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource-variable/lexend": "^5.2.11",
|
"@fontsource-variable/lexend": "^5.2.11",
|
||||||
"@fontsource-variable/manrope": "^5.2.8",
|
"@fontsource-variable/manrope": "^5.2.8",
|
||||||
"@fontsource-variable/nunito": "^5.2.7",
|
"@fontsource-variable/nunito": "^5.2.7",
|
||||||
@@ -803,6 +805,15 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@fontsource-variable/inter": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||||
@@ -812,6 +823,15 @@
|
|||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@fontsource-variable/lexend": {
|
||||||
"version": "5.2.11",
|
"version": "5.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||||
"@fontsource-variable/figtree": "^5.2.10",
|
"@fontsource-variable/figtree": "^5.2.10",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource-variable/lexend": "^5.2.11",
|
"@fontsource-variable/lexend": "^5.2.11",
|
||||||
"@fontsource-variable/manrope": "^5.2.8",
|
"@fontsource-variable/manrope": "^5.2.8",
|
||||||
"@fontsource-variable/nunito": "^5.2.7",
|
"@fontsource-variable/nunito": "^5.2.7",
|
||||||
@@ -46,8 +48,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/md5": "^2.3.5",
|
"@types/md5": "^2.3.5",
|
||||||
"@types/papaparse": "^5.5.2",
|
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.3.5",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
|||||||
+78
-7
@@ -8,8 +8,8 @@ mod discord;
|
|||||||
mod taskbar_win;
|
mod taskbar_win;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
|
||||||
@@ -28,6 +28,13 @@ const MAX_DL_CONCURRENCY: usize = 4;
|
|||||||
/// Shared semaphore that caps simultaneous `download_track_offline` executions.
|
/// Shared semaphore that caps simultaneous `download_track_offline` executions.
|
||||||
type DownloadSemaphore = Arc<tokio::sync::Semaphore>;
|
type DownloadSemaphore = Arc<tokio::sync::Semaphore>;
|
||||||
|
|
||||||
|
/// Per-job cancellation flags for `sync_batch_to_device`.
|
||||||
|
/// Each running sync registers an `Arc<AtomicBool>` here; `cancel_device_sync` flips it.
|
||||||
|
fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
|
||||||
|
static FLAGS: OnceLock<Mutex<HashMap<String, Arc<AtomicBool>>>> = 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.
|
/// 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.
|
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
|
||||||
type TrayState = Mutex<Option<TrayIcon>>;
|
type TrayState = Mutex<Option<TrayIcon>>;
|
||||||
@@ -1409,6 +1416,26 @@ fn get_removable_drives() -> Vec<RemovableDrive> {
|
|||||||
.collect()
|
.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<serde_json::Value> {
|
||||||
|
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
|
/// 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
|
/// filesystem). This prevents accidentally writing to `/media/usb` after the
|
||||||
/// USB drive has been unmounted — at that point the path would fall through to `/`
|
/// 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,
|
Ok(c) => c,
|
||||||
Err(_) => return false, // path doesn't exist or isn't accessible
|
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.
|
// Find the longest mount-point prefix that matches this path.
|
||||||
// Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback.
|
// Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback.
|
||||||
let mut best_len: usize = 0;
|
let mut best_len: usize = 0;
|
||||||
for disk in disks.list() {
|
for disk in disks.list() {
|
||||||
let mp = disk.mount_point().to_string_lossy().to_string();
|
let mp = disk.mount_point().to_string_lossy().to_string();
|
||||||
// Skip root mount points
|
// Skip root mount points (Linux "/" and non-removable Windows drive roots like "C:\").
|
||||||
if mp == "/" || (mp.len() == 3 && mp.ends_with(":\\")) {
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
if canonical_str.starts_with(&mp) && mp.len() > best_len {
|
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())
|
.map(|y| y.to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
template
|
let result = template
|
||||||
.replace("{artist}", &sanitize_path_component(&track.artist))
|
.replace("{artist}", &sanitize_path_component(&track.artist))
|
||||||
.replace("{album}", &sanitize_path_component(&track.album))
|
.replace("{album}", &sanitize_path_component(&track.album))
|
||||||
.replace("{title}", &sanitize_path_component(&track.title))
|
.replace("{title}", &sanitize_path_component(&track.title))
|
||||||
.replace("{track_number}", &track_number)
|
.replace("{track_number}", &track_number)
|
||||||
.replace("{disc_number}", &disc_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.
|
/// 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.
|
/// 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.
|
/// 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
|
/// 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.
|
// Shared reqwest client — reused across all downloads.
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.timeout(Duration::from_secs(300))
|
||||||
@@ -1927,10 +1984,14 @@ async fn sync_batch_to_device(
|
|||||||
let s = skipped.clone();
|
let s = skipped.clone();
|
||||||
let f = failed.clone();
|
let f = failed.clone();
|
||||||
let le = last_emit.clone();
|
let le = last_emit.clone();
|
||||||
|
let cancel = cancel_flag.clone();
|
||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.expect("semaphore closed");
|
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 relative = apply_device_sync_template(&tmpl, &track);
|
||||||
let file_name = format!("{}.{}", relative, track.suffix);
|
let file_name = format!("{}.{}", relative, track.suffix);
|
||||||
let dest_path = std::path::Path::new(&dest).join(&file_name);
|
let dest_path = std::path::Path::new(&dest).join(&file_name);
|
||||||
@@ -2024,6 +2085,12 @@ async fn sync_batch_to_device(
|
|||||||
let _ = handle.await;
|
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 {
|
let result = SyncBatchResult {
|
||||||
done: done.load(Ordering::Relaxed),
|
done: done.load(Ordering::Relaxed),
|
||||||
skipped: skipped.load(Ordering::Relaxed),
|
skipped: skipped.load(Ordering::Relaxed),
|
||||||
@@ -2037,6 +2104,7 @@ async fn sync_batch_to_device(
|
|||||||
"skipped": result.skipped,
|
"skipped": result.skipped,
|
||||||
"failed": result.failed,
|
"failed": result.failed,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"cancelled": was_cancelled,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -2507,11 +2575,14 @@ pub fn run() {
|
|||||||
purge_hot_cache,
|
purge_hot_cache,
|
||||||
sync_track_to_device,
|
sync_track_to_device,
|
||||||
sync_batch_to_device,
|
sync_batch_to_device,
|
||||||
|
cancel_device_sync,
|
||||||
compute_sync_paths,
|
compute_sync_paths,
|
||||||
list_device_dir_files,
|
list_device_dir_files,
|
||||||
delete_device_file,
|
delete_device_file,
|
||||||
delete_device_files,
|
delete_device_files,
|
||||||
get_removable_drives,
|
get_removable_drives,
|
||||||
|
write_device_manifest,
|
||||||
|
read_device_manifest,
|
||||||
toggle_tray_icon,
|
toggle_tray_icon,
|
||||||
check_dir_accessible,
|
check_dir_accessible,
|
||||||
download_zip,
|
download_zip,
|
||||||
|
|||||||
@@ -1085,6 +1085,7 @@ export const deTranslation = {
|
|||||||
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
|
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
|
||||||
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
|
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
|
||||||
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
|
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
|
||||||
|
manifestImported: '{{count}} Quelle(n) aus Geräte-Manifest importiert.',
|
||||||
selectedSources: 'Ausgewählte Quellen',
|
selectedSources: 'Ausgewählte Quellen',
|
||||||
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
|
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
|
||||||
clearAll: 'Alle entfernen',
|
clearAll: 'Alle entfernen',
|
||||||
@@ -1104,6 +1105,8 @@ export const deTranslation = {
|
|||||||
fetchError: 'Fehler beim Laden der Tracks vom Server.',
|
fetchError: 'Fehler beim Laden der Tracks vom Server.',
|
||||||
syncComplete: 'Übertragung abgeschlossen.',
|
syncComplete: 'Übertragung abgeschlossen.',
|
||||||
dismiss: 'Schließen',
|
dismiss: 'Schließen',
|
||||||
|
cancelSync: 'Abbrechen',
|
||||||
|
syncCancelled: 'Sync abgebrochen ({{done}} / {{total}} übertragen).',
|
||||||
colStatus: 'Status',
|
colStatus: 'Status',
|
||||||
statusSynced: 'Synchronisiert',
|
statusSynced: 'Synchronisiert',
|
||||||
statusPending: 'Ausstehend',
|
statusPending: 'Ausstehend',
|
||||||
|
|||||||
@@ -1088,6 +1088,7 @@ export const enTranslation = {
|
|||||||
deleteFromDevice: 'Mark for deletion ({{count}})',
|
deleteFromDevice: 'Mark for deletion ({{count}})',
|
||||||
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
|
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
|
||||||
deleteComplete: '{{count}} item(s) removed from device.',
|
deleteComplete: '{{count}} item(s) removed from device.',
|
||||||
|
manifestImported: 'Imported {{count}} source(s) from device manifest.',
|
||||||
selectedSources: 'Selected Sources',
|
selectedSources: 'Selected Sources',
|
||||||
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
|
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
|
||||||
clearAll: 'Clear all',
|
clearAll: 'Clear all',
|
||||||
@@ -1107,6 +1108,8 @@ export const enTranslation = {
|
|||||||
fetchError: 'Failed to fetch tracks from server.',
|
fetchError: 'Failed to fetch tracks from server.',
|
||||||
syncComplete: 'Sync complete.',
|
syncComplete: 'Sync complete.',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
|
cancelSync: 'Cancel',
|
||||||
|
syncCancelled: 'Sync cancelled ({{done}} / {{total}} transferred).',
|
||||||
statusSynced: 'Synced',
|
statusSynced: 'Synced',
|
||||||
statusPending: 'Pending',
|
statusPending: 'Pending',
|
||||||
statusDeletion: 'Deletion',
|
statusDeletion: 'Deletion',
|
||||||
|
|||||||
@@ -1103,6 +1103,8 @@ export const esTranslation = {
|
|||||||
fetchError: 'Error al obtener pistas del servidor.',
|
fetchError: 'Error al obtener pistas del servidor.',
|
||||||
syncComplete: 'Sincronización completada.',
|
syncComplete: 'Sincronización completada.',
|
||||||
dismiss: 'Cerrar',
|
dismiss: 'Cerrar',
|
||||||
|
cancelSync: 'Cancelar',
|
||||||
|
syncCancelled: 'Sincronización cancelada ({{done}} / {{total}} transferidos).',
|
||||||
colName: 'Nombre',
|
colName: 'Nombre',
|
||||||
colType: 'Tipo',
|
colType: 'Tipo',
|
||||||
colStatus: 'Estado',
|
colStatus: 'Estado',
|
||||||
@@ -1112,6 +1114,7 @@ export const esTranslation = {
|
|||||||
deleteFromDevice: 'Marcar para eliminar ({{count}})',
|
deleteFromDevice: 'Marcar para eliminar ({{count}})',
|
||||||
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
|
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
|
||||||
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
|
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
|
||||||
|
manifestImported: '{{count}} fuente(s) importada(s) desde el manifiesto del dispositivo.',
|
||||||
statusSynced: 'Sincronizado',
|
statusSynced: 'Sincronizado',
|
||||||
statusPending: 'Pendiente',
|
statusPending: 'Pendiente',
|
||||||
statusDeletion: 'Eliminación',
|
statusDeletion: 'Eliminación',
|
||||||
|
|||||||
@@ -1098,6 +1098,8 @@ export const frTranslation = {
|
|||||||
fetchError: 'Échec du chargement des pistes depuis le serveur.',
|
fetchError: 'Échec du chargement des pistes depuis le serveur.',
|
||||||
syncComplete: 'Synchronisation terminée.',
|
syncComplete: 'Synchronisation terminée.',
|
||||||
dismiss: 'Fermer',
|
dismiss: 'Fermer',
|
||||||
|
cancelSync: 'Annuler',
|
||||||
|
syncCancelled: 'Synchronisation annulée ({{done}} / {{total}} transférés).',
|
||||||
colName: 'Nom',
|
colName: 'Nom',
|
||||||
colType: 'Type',
|
colType: 'Type',
|
||||||
colStatus: 'Statut',
|
colStatus: 'Statut',
|
||||||
@@ -1107,6 +1109,7 @@ export const frTranslation = {
|
|||||||
deleteFromDevice: 'Marquer pour suppression ({{count}})',
|
deleteFromDevice: 'Marquer pour suppression ({{count}})',
|
||||||
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
|
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
|
||||||
deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.',
|
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é',
|
statusSynced: 'Synchronisé',
|
||||||
statusPending: 'En attente',
|
statusPending: 'En attente',
|
||||||
statusDeletion: 'Suppression',
|
statusDeletion: 'Suppression',
|
||||||
|
|||||||
@@ -1097,6 +1097,8 @@ export const nbTranslation = {
|
|||||||
fetchError: 'Kunne ikke hente spor fra serveren.',
|
fetchError: 'Kunne ikke hente spor fra serveren.',
|
||||||
syncComplete: 'Synkronisering fullført.',
|
syncComplete: 'Synkronisering fullført.',
|
||||||
dismiss: 'Lukk',
|
dismiss: 'Lukk',
|
||||||
|
cancelSync: 'Avbryt',
|
||||||
|
syncCancelled: 'Synkronisering avbrutt ({{done}} / {{total}} overført).',
|
||||||
colName: 'Navn',
|
colName: 'Navn',
|
||||||
colType: 'Type',
|
colType: 'Type',
|
||||||
colStatus: 'Status',
|
colStatus: 'Status',
|
||||||
@@ -1106,6 +1108,7 @@ export const nbTranslation = {
|
|||||||
deleteFromDevice: 'Merk for sletting ({{count}})',
|
deleteFromDevice: 'Merk for sletting ({{count}})',
|
||||||
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
|
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
|
||||||
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
|
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
|
||||||
|
manifestImported: '{{count}} kilde(r) importert fra enhetsmanifest.',
|
||||||
statusSynced: 'Synkronisert',
|
statusSynced: 'Synkronisert',
|
||||||
statusPending: 'Venter',
|
statusPending: 'Venter',
|
||||||
statusDeletion: 'Sletting',
|
statusDeletion: 'Sletting',
|
||||||
|
|||||||
@@ -1097,6 +1097,8 @@ export const nlTranslation = {
|
|||||||
fetchError: 'Ophalen van nummers van de server mislukt.',
|
fetchError: 'Ophalen van nummers van de server mislukt.',
|
||||||
syncComplete: 'Synchronisatie voltooid.',
|
syncComplete: 'Synchronisatie voltooid.',
|
||||||
dismiss: 'Sluiten',
|
dismiss: 'Sluiten',
|
||||||
|
cancelSync: 'Annuleren',
|
||||||
|
syncCancelled: 'Synchronisatie geannuleerd ({{done}} / {{total}} overgebracht).',
|
||||||
colName: 'Naam',
|
colName: 'Naam',
|
||||||
colType: 'Type',
|
colType: 'Type',
|
||||||
colStatus: 'Status',
|
colStatus: 'Status',
|
||||||
@@ -1106,6 +1108,7 @@ export const nlTranslation = {
|
|||||||
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
|
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
|
||||||
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
|
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
|
||||||
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
|
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
|
||||||
|
manifestImported: '{{count}} bron(nen) geïmporteerd uit apparaatmanifest.',
|
||||||
statusSynced: 'Gesynchroniseerd',
|
statusSynced: 'Gesynchroniseerd',
|
||||||
statusPending: 'In behandeling',
|
statusPending: 'In behandeling',
|
||||||
statusDeletion: 'Verwijdering',
|
statusDeletion: 'Verwijdering',
|
||||||
|
|||||||
@@ -1156,6 +1156,8 @@ export const ruTranslation = {
|
|||||||
fetchError: 'Ошибка загрузки треков с сервера.',
|
fetchError: 'Ошибка загрузки треков с сервера.',
|
||||||
syncComplete: 'Синхронизация завершена.',
|
syncComplete: 'Синхронизация завершена.',
|
||||||
dismiss: 'Закрыть',
|
dismiss: 'Закрыть',
|
||||||
|
cancelSync: 'Отмена',
|
||||||
|
syncCancelled: 'Синхронизация отменена ({{done}} / {{total}} перенесено).',
|
||||||
colName: 'Название',
|
colName: 'Название',
|
||||||
colType: 'Тип',
|
colType: 'Тип',
|
||||||
colStatus: 'Статус',
|
colStatus: 'Статус',
|
||||||
@@ -1165,6 +1167,7 @@ export const ruTranslation = {
|
|||||||
deleteFromDevice: 'Пометить для удаления ({{count}})',
|
deleteFromDevice: 'Пометить для удаления ({{count}})',
|
||||||
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
|
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
|
||||||
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
|
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
|
||||||
|
manifestImported: 'Импортировано {{count}} источник(ов) из манифеста устройства.',
|
||||||
statusSynced: 'Синхронизировано',
|
statusSynced: 'Синхронизировано',
|
||||||
statusPending: 'Ожидает',
|
statusPending: 'Ожидает',
|
||||||
statusDeletion: 'Удаление',
|
statusDeletion: 'Удаление',
|
||||||
|
|||||||
@@ -1091,6 +1091,8 @@ export const zhTranslation = {
|
|||||||
fetchError: '从服务器加载曲目失败。',
|
fetchError: '从服务器加载曲目失败。',
|
||||||
syncComplete: '同步完成。',
|
syncComplete: '同步完成。',
|
||||||
dismiss: '关闭',
|
dismiss: '关闭',
|
||||||
|
cancelSync: '取消',
|
||||||
|
syncCancelled: '同步已取消(已传输 {{done}} / {{total}})。',
|
||||||
colName: '名称',
|
colName: '名称',
|
||||||
colType: '类型',
|
colType: '类型',
|
||||||
colStatus: '状态',
|
colStatus: '状态',
|
||||||
@@ -1100,6 +1102,7 @@ export const zhTranslation = {
|
|||||||
deleteFromDevice: '标记为删除({{count}})',
|
deleteFromDevice: '标记为删除({{count}})',
|
||||||
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}?',
|
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}?',
|
||||||
deleteComplete: '已从设备删除 {{count}} 个项目。',
|
deleteComplete: '已从设备删除 {{count}} 个项目。',
|
||||||
|
manifestImported: '已从设备清单导入 {{count}} 个来源。',
|
||||||
statusSynced: '已同步',
|
statusSynced: '已同步',
|
||||||
statusPending: '待处理',
|
statusPending: '待处理',
|
||||||
statusDeletion: '删除中',
|
statusDeletion: '删除中',
|
||||||
|
|||||||
+59
-12
@@ -251,20 +251,27 @@ export default function DeviceSync() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const unlistenComplete = listen<{
|
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 }) => {
|
}>('device:sync:complete', ({ payload }) => {
|
||||||
const current = jobStore();
|
const current = jobStore();
|
||||||
if (current.jobId && payload.jobId === current.jobId) {
|
if (current.jobId && payload.jobId === current.jobId) {
|
||||||
useDeviceSyncJobStore.getState().complete(
|
if (payload.cancelled) {
|
||||||
payload.done, payload.skipped, payload.failed
|
useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed);
|
||||||
);
|
// status is already 'cancelled' from the button click; complete() would overwrite it — restore it
|
||||||
showToast(
|
useDeviceSyncJobStore.getState().cancel();
|
||||||
t('deviceSync.syncResult', {
|
} else {
|
||||||
done: payload.done, skipped: payload.skipped, total: payload.total
|
useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed);
|
||||||
}),
|
showToast(
|
||||||
5000, 'info'
|
t('deviceSync.syncResult', {
|
||||||
);
|
done: payload.done, skipped: payload.skipped, total: payload.total
|
||||||
// Re-scan the device after sync completes
|
}),
|
||||||
|
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();
|
scanDevice();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -344,7 +351,21 @@ export default function DeviceSync() {
|
|||||||
const handleChooseFolder = async () => {
|
const handleChooseFolder = async () => {
|
||||||
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
|
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
|
||||||
if (sel) {
|
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
|
// Trigger a device scan after folder change
|
||||||
setTimeout(() => scanDevice(), 100);
|
setTimeout(() => scanDevice(), 100);
|
||||||
}
|
}
|
||||||
@@ -401,6 +422,9 @@ export default function DeviceSync() {
|
|||||||
|
|
||||||
await invoke<number>('delete_device_files', { paths: allPaths });
|
await invoke<number>('delete_device_files', { paths: allPaths });
|
||||||
removeSources(deletionSources.map(s => s.id));
|
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(
|
showToast(
|
||||||
t('deviceSync.deleteComplete', { count: deletionSources.length }),
|
t('deviceSync.deleteComplete', { count: deletionSources.length }),
|
||||||
3000, 'info'
|
3000, 'info'
|
||||||
@@ -815,6 +839,29 @@ export default function DeviceSync() {
|
|||||||
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
|
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
|
||||||
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
|
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '2px 10px' }}
|
||||||
|
onClick={() => {
|
||||||
|
const jobId = useDeviceSyncJobStore.getState().jobId;
|
||||||
|
if (jobId) invoke('cancel_device_sync', { jobId });
|
||||||
|
useDeviceSyncJobStore.getState().cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('deviceSync.cancelSync')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobStatus === 'cancelled' && (
|
||||||
|
<div className="device-sync-bg-progress done">
|
||||||
|
<span className="device-sync-bg-progress-text">
|
||||||
|
<AlertCircle size={12} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
||||||
|
{t('deviceSync.dismiss')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+53
-25
@@ -406,6 +406,7 @@ export default function Settings() {
|
|||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [contributorsOpen, setContributorsOpen] = useState(false);
|
const [contributorsOpen, setContributorsOpen] = useState(false);
|
||||||
|
const [fontPickerOpen, setFontPickerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
|
||||||
@@ -1712,31 +1713,58 @@ export default function Settings() {
|
|||||||
<h2>{t('settings.font')}</h2>
|
<h2>{t('settings.font')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<button
|
||||||
{(
|
className="btn btn-ghost"
|
||||||
[
|
style={{ justifyContent: 'space-between', width: '100%', fontFamily: 'var(--font-sans)' }}
|
||||||
{ id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" },
|
onClick={() => setFontPickerOpen(o => !o)}
|
||||||
{ id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" },
|
>
|
||||||
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" },
|
<span>{
|
||||||
{ id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" },
|
([
|
||||||
{ id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" },
|
{ id: 'inter', label: 'Inter' },
|
||||||
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" },
|
{ id: 'outfit', label: 'Outfit' },
|
||||||
{ id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" },
|
{ id: 'dm-sans', label: 'DM Sans' },
|
||||||
{ id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" },
|
{ id: 'nunito', label: 'Nunito' },
|
||||||
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" },
|
{ id: 'rubik', label: 'Rubik' },
|
||||||
{ id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" },
|
{ id: 'space-grotesk', label: 'Space Grotesk' },
|
||||||
] as { id: FontId; label: string; stack: string }[]
|
{ id: 'figtree', label: 'Figtree' },
|
||||||
).map(f => (
|
{ id: 'manrope', label: 'Manrope' },
|
||||||
<button
|
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans' },
|
||||||
key={f.id}
|
{ id: 'lexend', label: 'Lexend' },
|
||||||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
{ id: 'geist', label: 'Geist' },
|
||||||
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
{ id: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||||
onClick={() => fontStore.setFont(f.id)}
|
] as { id: FontId; label: string }[]).find(f => f.id === fontStore.font)?.label ?? fontStore.font
|
||||||
>
|
}</span>
|
||||||
{f.label}
|
<ChevronDown size={14} style={{ color: 'var(--text-muted)', transform: fontPickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
{fontPickerOpen && (
|
||||||
</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '8px' }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
||||||
|
onClick={() => { fontStore.setFont(f.id); setFontPickerOpen(false); }}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface DeviceSyncJobState {
|
|||||||
startSync: (jobId: string, total: number) => void;
|
startSync: (jobId: string, total: number) => void;
|
||||||
updateProgress: (done: number, skipped: number, failed: number) => void;
|
updateProgress: (done: number, skipped: number, failed: number) => void;
|
||||||
complete: (done: number, skipped: number, failed: number) => void;
|
complete: (done: number, skipped: number, failed: number) => void;
|
||||||
|
cancel: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
|
|||||||
complete: (done, skipped, failed) =>
|
complete: (done, skipped, failed) =>
|
||||||
set({ done, skipped, failed, status: 'done' }),
|
set({ done, skipped, failed, status: 'done' }),
|
||||||
|
|
||||||
|
cancel: () =>
|
||||||
|
set({ status: 'cancelled' }),
|
||||||
|
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
|
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
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 {
|
interface FontState {
|
||||||
font: FontId;
|
font: FontId;
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
@import '@fontsource-variable/plus-jakarta-sans';
|
@import '@fontsource-variable/plus-jakarta-sans';
|
||||||
@import '@fontsource-variable/plus-jakarta-sans/wght-italic.css';
|
@import '@fontsource-variable/plus-jakarta-sans/wght-italic.css';
|
||||||
@import '@fontsource-variable/lexend';
|
@import '@fontsource-variable/lexend';
|
||||||
|
@import '@fontsource-variable/geist';
|
||||||
|
@import '@fontsource-variable/jetbrains-mono';
|
||||||
|
@import '@fontsource-variable/jetbrains-mono/wght-italic.css';
|
||||||
|
|
||||||
/* ─── Catppuccin Mocha Variables ─── */
|
/* ─── Catppuccin Mocha Variables ─── */
|
||||||
:root,
|
:root,
|
||||||
@@ -6399,6 +6402,16 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
|||||||
--font-display: 'Lexend Variable', sans-serif;
|
--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 ─── */
|
/* ─── Equalizer bars animation ─── */
|
||||||
.eq-bar {
|
.eq-bar {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Reference in New Issue
Block a user