feat: add tags/albums UI
This commit is contained in:
@@ -28,6 +28,8 @@ type SignedUrlResponse = {
|
||||
type PreviewUrlState = Record<string, string | undefined>;
|
||||
type VideoPlaybackVariant = { kind: "original" } | { kind: "video_mp4"; size: number };
|
||||
type VariantsResponse = Array<{ kind: string; size: number; key: string }>;
|
||||
type Tag = { id: string; name: string };
|
||||
type Album = { id: string; name: string };
|
||||
|
||||
function startOfDayUtc(iso: string) {
|
||||
const d = new Date(iso);
|
||||
@@ -57,6 +59,12 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
posterUrl: string | null;
|
||||
} | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [tagId, setTagId] = useState("");
|
||||
const [albumId, setAlbumId] = useState("");
|
||||
const [adminError, setAdminError] = useState<string | null>(null);
|
||||
const [adminBusy, setAdminBusy] = useState(false);
|
||||
|
||||
const range = useMemo(() => {
|
||||
if (!props.selectedDayIso) return null;
|
||||
@@ -174,12 +182,92 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
? "video_mp4"
|
||||
: playback.variant.kind;
|
||||
setViewer({ asset, url: playback.url, variant: variantLabel });
|
||||
void loadAdminLists();
|
||||
return;
|
||||
}
|
||||
|
||||
const variant: "original" | "thumb_med" | "poster" = "original";
|
||||
const url = await loadSignedUrl(asset.id, variant);
|
||||
setViewer({ asset, url, variant });
|
||||
void loadAdminLists();
|
||||
}
|
||||
|
||||
async function loadAdminLists() {
|
||||
setAdminError(null);
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) {
|
||||
setAdminError("Set admin token on /admin first.");
|
||||
return;
|
||||
}
|
||||
const headers = { "X-Porthole-Admin-Token": token };
|
||||
const [tagsRes, albumsRes] = await Promise.all([
|
||||
fetch("/api/tags", { headers, cache: "no-store" }),
|
||||
fetch("/api/albums", { headers, cache: "no-store" }),
|
||||
]);
|
||||
if (!tagsRes.ok) throw new Error(`tags_fetch_failed:${tagsRes.status}`);
|
||||
if (!albumsRes.ok)
|
||||
throw new Error(`albums_fetch_failed:${albumsRes.status}`);
|
||||
const tagsJson = (await tagsRes.json()) as Tag[];
|
||||
const albumsJson = (await albumsRes.json()) as Album[];
|
||||
setTags(tagsJson);
|
||||
setAlbums(albumsJson);
|
||||
} catch (err) {
|
||||
setAdminError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAdminBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignTag() {
|
||||
if (!viewer) return;
|
||||
setAdminError(null);
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
if (!tagId) throw new Error("select_tag");
|
||||
const res = await fetch(`/api/assets/${viewer.asset.id}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tagId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`tag_assign_failed:${res.status}`);
|
||||
setAdminError("Tag assigned.");
|
||||
} catch (err) {
|
||||
setAdminError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAdminBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToAlbum() {
|
||||
if (!viewer) return;
|
||||
setAdminError(null);
|
||||
setAdminBusy(true);
|
||||
try {
|
||||
const token = sessionStorage.getItem("porthole_admin_token") ?? "";
|
||||
if (!token) throw new Error("missing_admin_token");
|
||||
if (!albumId) throw new Error("select_album");
|
||||
const res = await fetch(`/api/albums/${albumId}/assets`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Porthole-Admin-Token": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ assetId: viewer.asset.id }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`album_add_failed:${res.status}`);
|
||||
setAdminError("Added to album.");
|
||||
} catch (err) {
|
||||
setAdminError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setAdminBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -431,6 +519,68 @@ export function MediaPanel(props: { selectedDayIso: string | null }) {
|
||||
<div style={{ color: "#666", fontSize: 12 }}>
|
||||
{viewer.asset.id}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #eee",
|
||||
paddingTop: 12,
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13 }}>Tags & Albums</strong>
|
||||
{adminError ? (
|
||||
<div style={{ color: "#b00", fontSize: 12 }}>
|
||||
{adminError}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "#555" }}>
|
||||
Assign tag
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<select
|
||||
value={tagId}
|
||||
onChange={(e) => setTagId(e.target.value)}
|
||||
style={{ flex: 1, padding: 6 }}
|
||||
disabled={adminBusy}
|
||||
>
|
||||
<option value="">Select tag</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={handleAssignTag}>
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<label style={{ fontSize: 12, color: "#555" }}>
|
||||
Add to album
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<select
|
||||
value={albumId}
|
||||
onChange={(e) => setAlbumId(e.target.value)}
|
||||
style={{ flex: 1, padding: 6 }}
|
||||
disabled={adminBusy}
|
||||
>
|
||||
<option value="">Select album</option>
|
||||
{albums.map((album) => (
|
||||
<option key={album.id} value={album.id}>
|
||||
{album.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={handleAddToAlbum}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#b00" }}>
|
||||
|
||||
Reference in New Issue
Block a user