Skip to content

Commit b978308

Browse files
Drive UX: grid click-to-open, responsive default view, download cue; unlock stuck self-update
- Grid/mosaic: a plain click now OPENS the file (like clicking the name in list view); modifiers select, long-press / tap-in-selection toggles. Double-click still opens. - Default view by screen size when nothing is saved: mosaic on small screens, list on large ones (the choice is still persisted in localStorage, as is sort). - Download cue: a small animated pill rises from the bottom-left when a download starts — useful feedback on mobile/PWA where downloads go silently to the tray. Honors prefers-reduced-motion. - Self-update: a 'running' status older than 10 min is treated as STUCK, so it no longer locks the panel forever. The admin sees a 'previous update looks stuck' banner with a Relaunch button (and POST /update accepts a retry over a stale status). Lets you test the fixed update flow.
1 parent 2cd6bdf commit b978308

9 files changed

Lines changed: 164 additions & 5 deletions

File tree

apps/api/src/routes/admin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
commitsBehind,
1818
getLocalVersion,
1919
getRemoteVersion,
20+
isUpdateStuck,
2021
readUpdateStatus,
2122
startUpdate,
2223
} from '../services/version.js';
@@ -159,6 +160,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
159160
const base = {
160161
current: local,
161162
status,
163+
// True when a "running" status is actually stuck (so the UI can offer a relaunch).
164+
stuck: isUpdateStuck(status),
162165
selfUpdateEnabled: app.ctx.env.SELF_UPDATE_ENABLED,
163166
repo: app.ctx.env.GITHUB_REPO,
164167
branch: app.ctx.env.UPDATE_BRANCH,

apps/api/src/services/version.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,24 @@ export interface StartUpdateResult {
162162
error?: string;
163163
}
164164

165+
// A real update never takes this long; a "running" status older than this is stuck (e.g. an old
166+
// build that was killed before the reparent fix) and must not lock out future updates forever.
167+
const STALE_RUNNING_MS = 10 * 60 * 1000;
168+
169+
/** True when the status is "running" but so old it's clearly stuck, not actually in progress. */
170+
export function isUpdateStuck(status: UpdateStatus): boolean {
171+
return status.state === 'running' && status.startedAt !== null && Date.now() - Date.parse(status.startedAt) > STALE_RUNNING_MS;
172+
}
173+
165174
/** Spawn the detached self-update script. It survives the PM2 reload it triggers. */
166175
export function startUpdate(env: Env): StartUpdateResult {
167176
if (!env.SELF_UPDATE_ENABLED) return { ok: false, error: 'Self-update is disabled on this instance.' };
168177
if (!repoRoot) return { ok: false, error: 'This deployment is not a git checkout; update it manually.' };
169-
if (readUpdateStatus().state === 'running') return { ok: false, error: 'An update is already running.' };
178+
const current = readUpdateStatus();
179+
// A genuinely in-progress update blocks a second one; a stuck/stale one does not.
180+
if (current.state === 'running' && !isUpdateStuck(current)) {
181+
return { ok: false, error: 'An update is already running.' };
182+
}
170183

171184
const script = join(repoRoot, 'scripts', 'self-update.sh');
172185
if (!existsSync(script)) return { ok: false, error: 'Update script not found (scripts/self-update.sh).' };

apps/web/src/app/(app)/admin/UpdatePanel.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface UpdateStatus {
3636
interface VersionInfo {
3737
current: LocalVersion;
3838
status: UpdateStatus;
39+
stuck?: boolean;
3940
selfUpdateEnabled: boolean;
4041
repo: string;
4142
branch: string;
@@ -126,7 +127,9 @@ export function UpdatePanel() {
126127
}
127128

128129
const { current, status, remote, updateAvailable, behindBy, selfUpdateEnabled } = info;
129-
const running = status.state === 'running';
130+
// A stuck "running" status (e.g. a previous update killed mid-flight) must not lock the panel.
131+
const stuck = !!info.stuck;
132+
const running = status.state === 'running' && !stuck;
130133

131134
return (
132135
<section className="card space-y-4">
@@ -171,6 +174,18 @@ export function UpdatePanel() {
171174
<Loader2 size={15} className="animate-spin" /> {status.message ?? t('admin.updateRunning')}
172175
</div>
173176
)}
177+
{stuck && (
178+
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-sm text-red-200">
179+
<span className="flex items-center gap-2">
180+
<AlertTriangle size={15} /> {t('admin.updateStuck')}
181+
</span>
182+
{selfUpdateEnabled && (
183+
<button className="btn-primary ml-auto px-2.5 py-1 text-xs" onClick={runUpdate} disabled={busy !== null}>
184+
<Download size={14} /> {t('admin.updateRelaunch')}
185+
</button>
186+
)}
187+
</div>
188+
)}
174189
{!running && status.state === 'success' && (
175190
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/[0.06] px-3 py-2 text-sm text-emerald-200">
176191
<CheckCircle2 size={15} /> {status.message ?? t('admin.updateSuccess')}

apps/web/src/app/(app)/drive/page.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
randomSalt,
6969
} from '@/lib/zk';
7070
import { fileVisual } from '@/lib/fileType';
71+
import { signalDownload } from '@/lib/downloadFx';
7172
import { FileViewer, type ViewerSource } from '@/components/FileViewer';
7273
import { confirm, prompt, choose, toast } from '@/components/ui/overlays';
7374
import { DropOverlay } from '@/components/drive/DropOverlay';
@@ -149,11 +150,13 @@ export default function EspacesPage() {
149150
const [passInput, setPassInput] = useState('');
150151
const [passError, setPassError] = useState<string | null>(null);
151152

152-
// Restore persisted view/sort once on mount.
153+
// Restore persisted view/sort once on mount. With no saved choice, default by screen size:
154+
// mosaic (grid) on small screens, list on large ones.
153155
useEffect(() => {
154156
try {
155157
const v = localStorage.getItem('ocl_view');
156158
if (v === 'grid' || v === 'list') setView(v);
159+
else setView(window.matchMedia('(max-width: 640px)').matches ? 'grid' : 'list');
157160
const s = localStorage.getItem('ocl_sort');
158161
if (s) {
159162
const parsed = JSON.parse(s) as Sort;
@@ -565,6 +568,7 @@ export default function EspacesPage() {
565568
a.download = name;
566569
a.click();
567570
URL.revokeObjectURL(url);
571+
signalDownload(name);
568572
}
569573

570574
function zkName(f: ZkFile) {
@@ -703,6 +707,21 @@ export default function EspacesPage() {
703707
e.stopPropagation();
704708
openEntry(entry);
705709
}
710+
// Grid/mosaic cards have no separate name target, so a plain click OPENS (like clicking the
711+
// name in list view); modifiers select, long-press / tap-in-selection toggles.
712+
function onCardClick(e: React.MouseEvent, entry: Entry) {
713+
if (Date.now() - pressHandledAt.current < 700) return; // click synthesized after a long-press
714+
if (pointerType.current === 'touch' && selected.size > 0) {
715+
toggleKey(entry.key);
716+
setAnchorKey(entry.key);
717+
return;
718+
}
719+
if (e.metaKey || e.ctrlKey || e.shiftKey) {
720+
onEntryClick(e, entry);
721+
return;
722+
}
723+
openEntry(entry);
724+
}
706725
function selectAll() {
707726
setSelected(new Set(entries.map((e) => e.key)));
708727
}
@@ -857,6 +876,7 @@ export default function EspacesPage() {
857876
a.href = api.url(`/files/${it.file.id}/download`);
858877
a.download = it.file.name;
859878
a.click();
879+
signalDownload(it.file.name);
860880
} else if (it.kind === 'zk') {
861881
const blob = await decryptZkBlob(it.zk);
862882
if (blob) saveBlob(blob, zkName(it.zk));
@@ -1450,7 +1470,7 @@ export default function EspacesPage() {
14501470
iconNode={cardIcon(e, isZk)}
14511471
name={e.name}
14521472
sub={e.kind === 'folder' ? undefined : formatBytes(e.size)}
1453-
onClick={(ev) => onEntryClick(ev, e)}
1473+
onClick={(ev) => onCardClick(ev, e)}
14541474
onDoubleClick={() => openEntry(e)}
14551475
onContextMenu={(ev) => onEntryContext(ev, e)}
14561476
press={pressProps(e)}
@@ -1495,7 +1515,7 @@ export default function EspacesPage() {
14951515
) : e.kind === 'file' ? (
14961516
<>
14971517
<IconBtn title={t('drive.open')} icon={Eye} onClick={() => openFile(e.file)} />
1498-
<a className="rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-zinc-100" title={t('drive.actionDownload')} href={api.url(`/files/${e.file.id}/download`)}>
1518+
<a className="rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-zinc-100" title={t('drive.actionDownload')} href={api.url(`/files/${e.file.id}/download`)} onClick={() => signalDownload(e.file.name)}>
14991519
<Download size={16} />
15001520
</a>
15011521
<Menu items={fileMenuItems(e.file)} />

apps/web/src/app/(app)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Wordmark } from '@/components/Wordmark';
1010
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
1111
import { StatusBanner } from '@/components/StatusBanner';
1212
import { OfflineBanner } from '@/components/OfflineBanner';
13+
import { DownloadIndicator } from '@/components/DownloadIndicator';
1314
import { formatBytes } from '@opencoperlock/shared/client';
1415

1516
const NAV = [
@@ -170,6 +171,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
170171
<StatusBanner />
171172
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 md:px-8 md:py-8">{children}</main>
172173
</div>
174+
<DownloadIndicator />
173175
</div>
174176
);
175177
}

apps/web/src/app/globals.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,46 @@ body {
111111
color: #fff;
112112
}
113113

114+
/* "Download started" cue — rises from the bottom-left and fades. */
115+
@keyframes ocl-dl-fly {
116+
0% {
117+
transform: translateY(18px);
118+
opacity: 0;
119+
}
120+
14% {
121+
transform: translateY(0);
122+
opacity: 1;
123+
}
124+
68% {
125+
opacity: 1;
126+
}
127+
100% {
128+
transform: translateY(-72px);
129+
opacity: 0;
130+
}
131+
}
132+
.ocl-dl {
133+
animation: ocl-dl-fly 1.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
134+
}
135+
@keyframes ocl-dl-arrow {
136+
0%,
137+
100% {
138+
transform: translateY(-1px);
139+
}
140+
50% {
141+
transform: translateY(3px);
142+
}
143+
}
144+
.ocl-dl-arrow {
145+
animation: ocl-dl-arrow 0.55s ease-in-out infinite;
146+
}
147+
@media (prefers-reduced-motion: reduce) {
148+
.ocl-dl,
149+
.ocl-dl-arrow {
150+
animation-duration: 0.01ms;
151+
}
152+
}
153+
114154
@layer components {
115155
.btn {
116156
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-50;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use client';
2+
3+
/**
4+
* Brief "download started" cue: a small pill that rises from the bottom-left and fades. Triggered
5+
* by signalDownload(name) (see lib/downloadFx). Gives feedback that a download began — handy on
6+
* mobile/PWA where the browser hides downloads in the notification tray.
7+
*/
8+
import { useEffect, useState } from 'react';
9+
import { DownloadCloud, ArrowDown } from 'lucide-react';
10+
import { useT } from '@/lib/i18n';
11+
12+
interface Cue {
13+
id: number;
14+
name: string;
15+
}
16+
17+
export function DownloadIndicator() {
18+
const { t } = useT();
19+
const [cues, setCues] = useState<Cue[]>([]);
20+
21+
useEffect(() => {
22+
let n = 0;
23+
const onDl = (e: Event) => {
24+
const name = (e as CustomEvent<{ name: string }>).detail?.name ?? '';
25+
const id = ++n;
26+
setCues((c) => [...c, { id, name }]);
27+
window.setTimeout(() => setCues((c) => c.filter((x) => x.id !== id)), 1700);
28+
};
29+
window.addEventListener('ocl:download', onDl);
30+
return () => window.removeEventListener('ocl:download', onDl);
31+
}, []);
32+
33+
if (cues.length === 0) return null;
34+
35+
return (
36+
<div className="pointer-events-none fixed bottom-6 left-4 z-[120] flex flex-col-reverse gap-2 sm:left-6">
37+
{cues.map((c) => (
38+
<div
39+
key={c.id}
40+
className="ocl-dl flex max-w-[14rem] items-center gap-2 rounded-full border border-violet-400/30 bg-violet-500/15 px-3 py-1.5 text-xs font-medium text-violet-100 shadow-glow backdrop-blur"
41+
>
42+
<span className="relative grid h-5 w-5 shrink-0 place-items-center text-violet-200">
43+
<DownloadCloud size={16} />
44+
<ArrowDown size={9} className="ocl-dl-arrow absolute" />
45+
</span>
46+
<span className="truncate">{c.name || t('download.started')}</span>
47+
</div>
48+
))}
49+
</div>
50+
);
51+
}

apps/web/src/lib/downloadFx.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Tiny pub-sub for a "download started" visual cue. Call signalDownload(name) wherever a download
3+
* is kicked off; the global <DownloadIndicator/> shows a brief animation. Especially useful on
4+
* mobile/PWA where a download silently goes to the notification tray with no in-page feedback.
5+
*/
6+
export function signalDownload(name?: string): void {
7+
if (typeof window === 'undefined') return;
8+
window.dispatchEvent(new CustomEvent('ocl:download', { detail: { name: name ?? '' } }));
9+
}

apps/web/src/lib/i18n.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const fr: Dict = {
5757
'offline.remove': 'Retirer',
5858
'offline.willSync': 'Ces fichiers seront envoyés et effacés de cet appareil dès le retour du réseau.',
5959
'offline.synced': '{n} fichier(s) synchronisé(s)',
60+
'download.started': 'Téléchargement…',
6061
// file viewer / editor
6162
'viewer.preview': 'Aperçu',
6263
'viewer.source': 'Source',
@@ -429,6 +430,8 @@ const fr: Dict = {
429430
'admin.currentVersionPre': 'Version actuelle',
430431
'admin.versionUnknown': 'Version inconnue — ce déploiement n’est pas un dépôt git, mettez à jour manuellement.',
431432
'admin.updateRunning': 'Mise à jour en cours…',
433+
'admin.updateStuck': 'La mise à jour précédente semble bloquée. Vous pouvez la relancer.',
434+
'admin.updateRelaunch': 'Relancer la mise à jour',
432435
'admin.updateSuccess': 'Dernière mise à jour réussie.',
433436
'admin.updateFailedPre': 'Échec :',
434437
'admin.updateFailedDefault': 'voir .update.log sur le serveur.',
@@ -577,6 +580,7 @@ const en: Dict = {
577580
'offline.remove': 'Remove',
578581
'offline.willSync': 'These files will be uploaded and removed from this device as soon as the network returns.',
579582
'offline.synced': '{n} file(s) synced',
583+
'download.started': 'Downloading…',
580584
'viewer.preview': 'Preview',
581585
'viewer.source': 'Source',
582586
'viewer.runJs': 'JS',
@@ -932,6 +936,8 @@ const en: Dict = {
932936
'admin.currentVersionPre': 'Current version',
933937
'admin.versionUnknown': 'Unknown version — this deployment is not a git repository, update manually.',
934938
'admin.updateRunning': 'Update in progress…',
939+
'admin.updateStuck': 'The previous update looks stuck. You can relaunch it.',
940+
'admin.updateRelaunch': 'Relaunch update',
935941
'admin.updateSuccess': 'Last update succeeded.',
936942
'admin.updateFailedPre': 'Failed:',
937943
'admin.updateFailedDefault': 'see .update.log on the server.',

0 commit comments

Comments
 (0)