refactor(ui): drain src/components into ui + owning features

The legacy src/components/ dir held eight shared modals. Audited each: the five
generic primitives (Modal, ConfirmModal, GlobalConfirmModal, ExportPickerModal,
ThemeMigrationNotice) move to src/ui (the shared-primitive home). The three that
import @/features/* are feature-owned, so they move into their feature instead of
polluting ui with core->feature edges: SongInfoModal -> features/playback,
LicenseTextModal -> features/settings, PasteClipboardHandler -> features/share.

Pure move + import updates, no behavior change. The layering baseline is unchanged
(every relocated import lands in a legal direction: feature->ui/store/lib, or
cross-feature only via the barrel). src/components/ is now removed.
This commit is contained in:
Psychotoxical
2026-07-01 14:31:33 +02:00
parent f4fa766b20
commit 25055dc79a
18 changed files with 18 additions and 18 deletions
@@ -4,7 +4,7 @@ import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '@/features/orbit/utils/orbit';
import ConfirmModal from '@/components/ConfirmModal';
import ConfirmModal from '@/ui/ConfirmModal';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
@@ -18,7 +18,7 @@ import OrbitExitModal from '@/features/orbit/components/OrbitExitModal';
import OrbitSettingsPopover from '@/features/orbit/components/OrbitSettingsPopover';
import OrbitSharePopover from '@/features/orbit/components/OrbitSharePopover';
import OrbitDiagnosticsPopover from '@/features/orbit/components/OrbitDiagnosticsPopover';
import ConfirmModal from '@/components/ConfirmModal';
import ConfirmModal from '@/ui/ConfirmModal';
import { formatTrackTime } from '@/lib/format/formatDuration';
/**
@@ -0,0 +1,244 @@
import { getSong } from '@/lib/api/subsonicLibrary';
import { libraryGetFacts } from '@/lib/api/library';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { ndGetSongPath } from '@/lib/api/navidromeAdmin';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '@/lib/server/serverMagicString';
import { showToast } from '@/lib/dom/toast';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { formatLastSeen } from '@/lib/format/userMgmtHelpers';
import { libraryIsReady } from '@/lib/library/libraryReady';
import {
formatQueueMoodLabels,
parseTrackEnrichmentFacts,
resolveQueueBpm,
type ParsedTrackEnrichment,
} from '@/lib/library/trackEnrichment';
import i18n from '@/lib/i18n';
function formatSize(bytes?: number): string | null {
if (!bytes) return null;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
return `${(bytes / 1_000).toFixed(0)} KB`;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === '' || value === '—') return null;
return (
<tr>
<td className="song-info-label">{label}</td>
<td className="song-info-value">{value}</td>
</tr>
);
}
/** Title / Artist / Album: double-click the value cell to copy plain text. */
function CopyableFieldRow({ label, text }: { label: string; text: string | null | undefined }) {
const { t } = useTranslation();
if (!text || text === '—') return null;
const onDoubleClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const ok = await copyTextToClipboard(text);
if (ok) showToast(t('orbit.tooltipCopied'), 2000, 'info');
else showToast(t('contextMenu.shareCopyFailed'), 3500, 'error');
};
return (
<tr>
<td className="song-info-label">{label}</td>
<td className="song-info-value song-info-value--no-select" onDoubleClick={onDoubleClick}>
{text}
</td>
</tr>
);
}
function Divider() {
return <tr><td colSpan={2} className="song-info-divider" /></tr>;
}
export default function SongInfoModal() {
const { t } = useTranslation();
const { songInfoModal, closeSongInfo } = usePlayerStore(
useShallow(s => ({ songInfoModal: s.songInfoModal, closeSongInfo: s.closeSongInfo }))
);
const [song, setSong] = useState<SubsonicSong | null>(null);
const [enrichment, setEnrichment] = useState<ParsedTrackEnrichment | null>(null);
const [loading, setLoading] = useState(false);
// Absolute filesystem path resolved via Navidrome's native API in parallel
// with the Subsonic getSong call. Subsonic only ever returns a relative
// path (or none on Navidrome); the native endpoint is what Feishin and the
// Navidrome web client use to surface the full server-side location.
const [absolutePath, setAbsolutePath] = useState<string | null>(null);
useEffect(() => {
if (!songInfoModal.isOpen || !songInfoModal.songId) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSong(null);
setEnrichment(null);
setAbsolutePath(null);
return;
}
let cancelled = false;
setLoading(true);
setEnrichment(null);
setAbsolutePath(null);
const songId = songInfoModal.songId;
void (async () => {
const s = await getSong(songId);
if (cancelled) return;
setSong(s);
setLoading(false);
if (!s) {
setEnrichment(null);
return;
}
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false;
if (sid && indexEnabled && await libraryIsReady(sid)) {
try {
const facts = await libraryGetFacts(sid, songId);
if (!cancelled) {
setEnrichment(parseTrackEnrichmentFacts(facts, s.bpm ?? null));
}
} catch {
if (!cancelled) setEnrichment(null);
}
} else if (!cancelled) {
setEnrichment(null);
}
})();
// Try the native API in parallel; only when the active server is Navidrome
// and we have credentials. Failures are silent — modal falls back to
// whatever the Subsonic `path` field carried (typically nothing).
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const profile = sid ? auth.servers.find(p => p.id === sid) : null;
const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined;
const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome';
if (isNavidrome && profile?.url && profile.username && profile.password) {
const serverUrl = (profile.url.startsWith('http') ? profile.url : `http://${profile.url}`).replace(/\/$/, '');
ndGetSongPath(serverUrl, profile.username, profile.password, songId).then(p => {
if (!cancelled && p) setAbsolutePath(p);
});
}
return () => { cancelled = true; };
}, [songInfoModal.isOpen, songInfoModal.songId]);
useEffect(() => {
if (!songInfoModal.isOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeSongInfo(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [songInfoModal.isOpen, closeSongInfo]);
if (!songInfoModal.isOpen) return null;
const channels = song?.channelCount === 1
? t('songInfo.mono')
: song?.channelCount === 2
? t('songInfo.stereo')
: song?.channelCount
? `${song.channelCount} ch`
: null;
const trackLabel = song?.discNumber && song.discNumber > 1
? `${song.discNumber} ${song.track}`
: song?.track != null
? String(song.track)
: null;
const hasReplayGain = song?.replayGain &&
(song.replayGain.trackGain !== undefined || song.replayGain.albumGain !== undefined);
const displayBpm = song
? resolveQueueBpm(
enrichment ?? {
serverBpm: song.bpm != null && song.bpm > 0 ? song.bpm : null,
measuredBpm: null,
moodLabels: [],
},
)
: null;
const displayMood = enrichment ? formatQueueMoodLabels(enrichment.moodLabels, t) : null;
return createPortal(
<>
<div className="song-info-backdrop" onClick={closeSongInfo} />
<div className="song-info-modal" role="dialog" aria-modal="true" aria-label={t('songInfo.title')}>
<div className="song-info-header">
<span className="song-info-title">{t('songInfo.title')}</span>
<button className="btn btn-ghost song-info-close" onClick={closeSongInfo} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="song-info-body">
{loading && <div className="song-info-loading">{t('common.loading')}</div>}
{!loading && song && (
<table className="song-info-table">
<tbody>
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
<Row label={t('songInfo.year')} value={song.year} />
<Row label={t('songInfo.genre')} value={song.genre} />
<Row label={t('songInfo.duration')} value={formatTrackTime(song.duration)} />
<Row label={t('songInfo.track')} value={trackLabel} />
<Row label={t('songInfo.bpm')} value={displayBpm} />
<Row label={t('songInfo.mood')} value={displayMood} />
<Row label={t('songInfo.playCount')} value={song.playCount} />
<Row label={t('songInfo.lastPlayed')} value={song.played ? formatLastSeen(song.played, i18n.language, '—') : null} />
<Divider />
<Row label={t('songInfo.format')} value={[song.suffix?.toUpperCase(), song.contentType].filter(Boolean).join(' · ') || null} />
<Row label={t('songInfo.bitrate')} value={song.bitRate ? `${song.bitRate} kbps` : null} />
<Row label={t('songInfo.sampleRate')} value={song.samplingRate ? `${(song.samplingRate / 1000).toFixed(1)} kHz` : null} />
<Row label={t('songInfo.bitDepth')} value={song.bitDepth ? `${song.bitDepth} bit` : null} />
<Row label={t('songInfo.channels')} value={channels} />
<Row label={t('songInfo.fileSize')} value={formatSize(song.size)} />
{(absolutePath || song.path) && (
<>
<Divider />
<Row label={t('songInfo.path')} value={<span className="song-info-path">{absolutePath ?? song.path}</span>} />
</>
)}
{hasReplayGain && (
<>
<Divider />
{song.replayGain!.trackGain !== undefined && (
<Row label={t('songInfo.replayGainTrack')} value={`${song.replayGain!.trackGain >= 0 ? '+' : ''}${song.replayGain!.trackGain.toFixed(2)} dB`} />
)}
{song.replayGain!.albumGain !== undefined && (
<Row label={t('songInfo.replayGainAlbum')} value={`${song.replayGain!.albumGain >= 0 ? '+' : ''}${song.replayGain!.albumGain.toFixed(2)} dB`} />
)}
{song.replayGain!.trackPeak !== undefined && (
<Row label={t('songInfo.replayGainPeak')} value={song.replayGain!.trackPeak.toFixed(6)} />
)}
</>
)}
</tbody>
</table>
)}
</div>
</div>
</>,
document.body
);
}
@@ -0,0 +1,132 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { open as openExternal } from '@tauri-apps/plugin-shell';
import type { LicenseEntry } from '@/features/settings/utils/licensesData';
interface Props {
entry: LicenseEntry | null;
onClose: () => void;
}
export default function LicenseTextModal({ entry, onClose }: Props) {
const { t } = useTranslation();
useEffect(() => {
if (!entry) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [entry, onClose]);
if (!entry) return null;
const repoLink = entry.repository || entry.homepage;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '760px',
width: '92vw',
maxHeight: '82vh',
display: 'flex',
flexDirection: 'column',
}}
>
<button className="modal-close" onClick={onClose} aria-label={t('common.close')}>
<X size={18} />
</button>
<div style={{ marginBottom: '0.5rem', paddingRight: '2rem' }}>
<h3 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: '1.05rem' }}>
{entry.name} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>{entry.version}</span>
</h3>
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: '0.4rem',
fontSize: '0.78rem',
}}
>
{entry.licenses.map((lid) => (
<span
key={lid}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '10px',
padding: '1px 8px',
color: 'var(--text-primary)',
}}
>
{lid}
</span>
))}
<span
style={{
color: 'var(--text-muted)',
textTransform: 'uppercase',
fontSize: '0.7rem',
letterSpacing: '0.04em',
}}
>
{entry.source}
</span>
{repoLink && (
<button
onClick={() => openExternal(repoLink)}
className="btn btn-ghost"
style={{
padding: '2px 8px',
fontSize: '0.78rem',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
>
<ExternalLink size={12} /> {t('licenses.viewSource')}
</button>
)}
</div>
{entry.description && (
<p style={{ color: 'var(--text-muted)', marginTop: '0.6rem', marginBottom: 0, fontSize: '0.85rem' }}>
{entry.description}
</p>
)}
</div>
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '12px 14px',
overflow: 'auto',
flex: 1,
fontFamily: 'var(--font-mono, ui-monospace, monospace)',
fontSize: '0.78rem',
whiteSpace: 'pre-wrap',
color: 'var(--text-primary)',
lineHeight: 1.5,
}}
>
{entry.licenseText || t('licenses.noLicenseText')}
</div>
</div>
</div>,
document.body,
);
}
@@ -9,7 +9,7 @@ import {
type LicenseEntry,
type LicensesData,
} from '@/features/settings/utils/licensesData';
import LicenseTextModal from '@/components/LicenseTextModal';
import LicenseTextModal from './LicenseTextModal';
const ROW_HEIGHT = 56;
@@ -6,7 +6,7 @@ import { open } from '@tauri-apps/plugin-dialog';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { validateThemePackage, type ValidatedTheme } from '@/lib/themes/validateThemePackage';
import { showToast } from '@/lib/dom/toast';
import ConfirmModal from '@/components/ConfirmModal';
import ConfirmModal from '@/ui/ConfirmModal';
/**
* Import a community theme from a local `.zip` (manifest.json + theme.css).
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RotateCcw, UserPlus, Users } from 'lucide-react';
import type { NdUser } from '@/lib/api/navidromeAdmin';
import ConfirmModal from '@/components/ConfirmModal';
import ConfirmModal from '@/ui/ConfirmModal';
import { useUserMgmtData } from '@/features/settings/hooks/useUserMgmtData';
import { useUserMgmtActions } from '@/features/settings/hooks/useUserMgmtActions';
import { UserForm } from '@/features/settings/components/UserForm';
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import type { EntitySharePayloadV1 } from '@/lib/share/shareLink';
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
import { decodeServerMagicStringFromText } from '@/lib/server/serverMagicString';
import { applySharePastePayload, applySharePasteQueue } from '@/features/share/applySharePaste';
import { shareQueueServerContext } from '@/lib/share/shareServerOriginLabel';
import { showToast } from '@/lib/dom/toast';
import { useShareQueuePreview } from '@/features/search';
import { ShareQueuePreviewModal } from '@/features/search';
import {
parseOrbitShareLink,
joinOrbitSession,
findSessionPlaylistId,
readOrbitState,
OrbitJoinError,
} from '@/features/orbit';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { useOrbitAccountPickerStore } from '@/features/orbit';
import ConfirmModal from '@/ui/ConfirmModal';
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
'not-found': 'orbit.joinErrNotFound',
'ended': 'orbit.joinErrEnded',
'full': 'orbit.joinErrFull',
'kicked': 'orbit.joinErrKicked',
'no-user': 'orbit.joinErrNoUser',
'server-error': 'orbit.joinErrServerError',
};
/**
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
* outside text fields. Shares require login; invites open add-server (settings or login).
*/
type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
export default function PasteClipboardHandler() {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const busy = useRef(false);
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
const [orbitInvalid, setOrbitInvalid] = useState(false);
const [queuePaste, setQueuePaste] = useState<QueuePastePayload | null>(null);
const [queuePasteBusy, setQueuePasteBusy] = useState(false);
const queuePreview = useShareQueuePreview(queuePaste, !!queuePaste);
const { label: queuePasteServerLabel, coverServer: queuePasteCoverServer } = useMemo(
() =>
queuePaste
? shareQueueServerContext(queuePaste.srv, servers, activeServerId)
: { label: null, coverServer: null },
[queuePaste, servers, activeServerId],
);
// `not-found` and `ended` collapse into a single "link no longer valid"
// dialog — from the guest's POV both mean the same thing: the invite
// doesn't lead anywhere any more. Other reasons stay as toasts because
// they're actionable (full → wait, kicked → talk to host, etc.).
const handleJoinError = (reason: string | null, fallback?: string) => {
if (reason === 'not-found' || reason === 'ended') {
setOrbitInvalid(true);
return;
}
const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null;
showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error');
};
const runOrbitJoin = (sid: string) => {
if (busy.current) return;
busy.current = true;
joinOrbitSession(sid)
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
.catch(err => {
if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message);
else handleJoinError(null);
})
.finally(() => { busy.current = false; });
};
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
const text = e.clipboardData?.getData('text/plain') ?? '';
// Orbit share link — handled before library shares.
const orbit = parseOrbitShareLink(text.trim());
if (orbit) {
e.preventDefault();
e.stopPropagation();
if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; }
if (busy.current) return;
busy.current = true;
(async () => {
const active = useAuthStore.getState().getActiveServer();
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
// Auto-switch to the link's target server if the user has an
// account registered for it. No account → clear error. Multiple
// accounts for the same URL → picker lets the user choose. The
// switch itself tears down any lingering orbit session (see
// switchActiveServer) so the join below starts clean.
if (activeUrl !== wantUrl) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
if (candidates.length === 0) {
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
return;
}
const target = candidates.length === 1
? candidates[0]
: await useOrbitAccountPickerStore.getState().request(candidates);
if (!target) return; // cancelled
const switched = await switchActiveServer(target);
if (!switched) {
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
return;
}
}
// Preview the session state so the confirm dialog can show the
// host and session name. Failures surface the same error toasts
// the join would, without ever showing the confirm.
const playlistId = await findSessionPlaylistId(orbit.sid);
if (!playlistId) { handleJoinError('not-found'); return; }
const state = await readOrbitState(playlistId);
if (!state) { handleJoinError('not-found'); return; }
if (state.ended) { handleJoinError('ended'); return; }
setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name });
})()
.catch(() => handleJoinError(null))
.finally(() => { busy.current = false; });
return;
}
const share = decodeSharePayloadFromText(text);
if (share) {
if (!isLoggedIn) {
e.preventDefault();
e.stopPropagation();
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
return;
}
if (busy.current || queuePaste) {
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
if (share.k === 'queue') {
if (share.ids.length === 0) {
showToast(t('sharePaste.genericError'), 5000, 'error');
return;
}
setQueuePaste(share);
return;
}
busy.current = true;
void applySharePastePayload(share, navigate, t, location).finally(() => {
busy.current = false;
});
return;
}
const invite = decodeServerMagicStringFromText(text);
if (!invite) return;
if (busy.current) {
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
busy.current = true;
if (isLoggedIn) {
navigate('/settings', { state: { tab: 'server' as const, openAddServerInvite: invite } });
} else {
navigate('/login', { state: { openAddServerInvite: invite } });
}
queueMicrotask(() => {
busy.current = false;
});
};
document.addEventListener('paste', onPaste, true);
return () => document.removeEventListener('paste', onPaste, true);
// handleJoinError and the router location are captured by the onPaste closure;
// the global paste listener is intentionally not re-registered on every render
// or navigation, only when the auth/handler inputs below change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate, t, isLoggedIn, queuePaste]);
const closeQueuePaste = () => {
if (queuePasteBusy) return;
setQueuePaste(null);
};
const confirmQueuePaste = async () => {
if (!queuePaste || queuePasteBusy) return;
setQueuePasteBusy(true);
const ok = await applySharePasteQueue(queuePaste, t);
setQueuePasteBusy(false);
if (ok) setQueuePaste(null);
};
return (
<>
{queuePaste && (
<ShareQueuePreviewModal
open
onClose={closeQueuePaste}
payload={queuePaste}
preview={queuePreview}
shareServerLabel={queuePasteServerLabel}
coverServer={queuePasteCoverServer}
onEnqueue={() => void confirmQueuePaste()}
enqueueBusy={queuePasteBusy}
confirmLabel={t('sharePaste.playQueue')}
confirmBusyLabel={t('sharePaste.playQueueing')}
/>
)}
<ConfirmModal
open={!!orbitConfirm}
title={t('orbit.confirmJoinTitle')}
message={t('orbit.confirmJoinBody', {
host: orbitConfirm?.host ?? '',
name: orbitConfirm?.name ?? '',
})}
confirmLabel={t('orbit.confirmJoinConfirm')}
cancelLabel={t('orbit.confirmCancel')}
onConfirm={() => {
const sid = orbitConfirm?.sid;
setOrbitConfirm(null);
if (sid) runOrbitJoin(sid);
}}
onCancel={() => setOrbitConfirm(null)}
/>
<ConfirmModal
open={orbitInvalid}
title={t('orbit.invalidLinkTitle')}
message={t('orbit.invalidLinkBody')}
confirmLabel={t('orbit.exitOk')}
onConfirm={() => setOrbitInvalid(false)}
/>
</>
);
}
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '@/../package.json';
import { formatBytes } from '@/lib/format/formatBytes';
import { useAppUpdater } from '@/features/updater/hooks/useAppUpdater';
import Modal from '@/components/Modal';
import Modal from '@/ui/Modal';
import Changelog from '@/features/updater/components/Changelog';
export default function AppUpdater() {