feat: offline content
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SaveOfflineButton — sits beside "Copy link to this page" in PageHeader.
|
||||
*
|
||||
* Saves the current page (and, for transcripts, its audio) for offline
|
||||
* reading/listening via lib/offline. Shows idle → saving(%) → saved states and
|
||||
* lets the user remove a saved copy. Renders nothing when the browser can't
|
||||
* support offline storage.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Download, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
isSaved as isSavedFn,
|
||||
offlineSupported,
|
||||
pageKey,
|
||||
removeOffline,
|
||||
saveOffline,
|
||||
type OfflineDescriptor,
|
||||
} from "@/lib/offline";
|
||||
|
||||
type State = "idle" | "saving" | "saved";
|
||||
|
||||
export function SaveOfflineButton({
|
||||
descriptor,
|
||||
}: {
|
||||
descriptor: OfflineDescriptor;
|
||||
}) {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [state, setState] = useState<State>("idle");
|
||||
const [pct, setPct] = useState<number | null>(null);
|
||||
const keyRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!offlineSupported()) return;
|
||||
setSupported(true);
|
||||
keyRef.current = pageKey();
|
||||
let alive = true;
|
||||
isSavedFn(keyRef.current).then((saved) => {
|
||||
if (alive && saved) setState("saved");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
async function onClick() {
|
||||
if (state === "saving") return;
|
||||
|
||||
if (state === "saved") {
|
||||
// Toggle off — remove the offline copy.
|
||||
try {
|
||||
await removeOffline(keyRef.current);
|
||||
setState("idle");
|
||||
toast("Removed from offline", { duration: 1800 });
|
||||
} catch {
|
||||
toast.error("Couldn't remove the offline copy", { duration: 3000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState("saving");
|
||||
setPct(descriptor.audioUrl ? 0 : null);
|
||||
try {
|
||||
const res = await saveOffline(descriptor, (f) =>
|
||||
setPct(Math.round(f * 100)),
|
||||
);
|
||||
setState("saved");
|
||||
if (res.audioError) {
|
||||
toast(res.audioError, { duration: 4000 });
|
||||
} else {
|
||||
toast("Saved for offline", { duration: 1800 });
|
||||
}
|
||||
} catch {
|
||||
setState("idle");
|
||||
toast.error("Couldn't save this page offline", { duration: 3000 });
|
||||
} finally {
|
||||
setPct(null);
|
||||
}
|
||||
}
|
||||
|
||||
const label =
|
||||
state === "saving"
|
||||
? pct !== null
|
||||
? `Saving… ${pct}%`
|
||||
: "Saving…"
|
||||
: state === "saved"
|
||||
? "Saved offline"
|
||||
: "Save offline";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={
|
||||
state === "saved" ? "Remove offline copy" : "Save this page for offline use"
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors",
|
||||
state === "saved"
|
||||
? "text-foreground border-primary/40 hover:text-destructive hover:border-destructive/40 group"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{state === "saving" ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : state === "saved" ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-primary group-hover:hidden" />
|
||||
<Trash2 className="size-3.5 hidden group-hover:block" />
|
||||
</>
|
||||
) : (
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user