Align shared recording library and viewer with dashboard styling

This commit is contained in:
webadderall
2026-09-23 14:58:53 +10:00
parent bde5a570bb
commit 24b510135c
23 changed files with 1520 additions and 616 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,2 +1,2 @@
<!DOCTYPE html><html data-astro-cid-mklzptps> <head><meta charset="utf-8"><style>[data-astro-cid-mklzptps]{margin:0;padding:0}html,body{width:100%;height:100%;background:#000;overflow:hidden}video[data-astro-cid-mklzptps]{width:100%;height:100%;object-fit:contain}
<!DOCTYPE html><html lang="en" data-astro-cid-mklzptps> <head><meta charset="utf-8"><style>[data-astro-cid-mklzptps]{margin:0;padding:0}html,body{width:100%;height:100%;background:#000;overflow:hidden}video[data-astro-cid-mklzptps]{width:100%;height:100%;object-fit:contain}
</style></head> <body data-astro-cid-mklzptps> <video id="player" controls autoplay playsinline data-astro-cid-mklzptps></video> <script type="module">const e=window.location.pathname,o=e.split("/").pop()||"",t=document.getElementById("player");t.poster=`/og/${o}`;t.src=`/v/${o}`;</script> </body> </html>
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,216 +1,614 @@
import { Button, Card, Chip, Input, Label, Link, Skeleton, TextField, toast } from '@heroui/react';
import ThemeToggle from "./ThemeToggle";
import {
ArrowClockwiseIcon,
ArrowSquareOutIcon,
LockKeyIcon,
TrashIcon,
} from '@phosphor-icons/react';
import { useCallback, useEffect, useState } from 'react';
Button,
Card,
Dropdown,
Input,
Label,
Modal,
Skeleton,
TextField,
toast,
} from "@heroui/react";
import {
fetchVideos,
renewVideo,
deleteVideo,
formatDate,
formatDuration,
daysUntilExpiry,
type LibraryVideo,
} from '../scripts/library';
import { CenteredCard, CopyButton, Header, Notice, Notifications } from './ShareUI';
CaretDownIcon,
CloudIcon,
DotsThreeIcon,
FolderSimpleIcon,
GearSixIcon,
LockKeyIcon,
MagnifyingGlassIcon,
PlusIcon,
SignOutIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";
import {
fetchVideos,
renewVideo,
deleteVideo,
parseUTC,
type LibraryVideo,
} from "../scripts/library";
import { CenteredCard, Notice, Notifications } from "./ShareUI";
import { LibraryCard, type LibraryFolder } from "./library/LibraryCard";
export function LibraryLogin() {
const [error, setError] = useState(false);
useEffect(() => setError(new URLSearchParams(location.search).has('error')), []);
return (
<CenteredCard>
<LockKeyIcon size={28} />
<h1>Your library</h1>
<p>Enter your dashboard password to manage shared recordings.</p>
<form method="POST" action="/library/login" className="gate-form">
<TextField name="password" isRequired>
<Label>Password</Label>
<Input type="password" autoComplete="current-password" autoFocus />
</TextField>
{error && <Notice>Incorrect password. Please try again.</Notice>}
<Button type="submit">Unlock library</Button>
</form>
</CenteredCard>
);
const [error, setError] = useState(false);
useEffect(() => setError(new URLSearchParams(location.search).has("error")), []);
return (
<CenteredCard>
<LockKeyIcon size={28} />
<h1>Your library</h1>
<p>Enter your dashboard password to manage shared recordings.</p>
<form method="POST" action="/library/login" className="gate-form">
<TextField name="password" isRequired>
<Label>Password</Label>
<Input type="password" autoComplete="current-password" autoFocus />
</TextField>
{error && <Notice>Incorrect password. Please try again.</Notice>}
<Button type="submit">Unlock library</Button>
</form>
</CenteredCard>
);
}
export default function LibraryPage() {
const [videos, setVideos] = useState<LibraryVideo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [pending, setPending] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
setVideos(await fetchVideos());
} catch {
setError('Could not load your recordings. Please try again.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function renew(code: string) {
setPending(code);
try {
const expires = await renewVideo(code);
if (!expires) throw new Error();
setVideos((current) =>
current.map((video) =>
video.share_code === code ? { ...video, expires_at: expires } : video,
),
);
toast.success('Share link renewed');
} catch {
toast.danger('Could not renew this link. Try again.');
} finally {
setPending(null);
}
}
async function remove(code: string) {
setPending(code);
try {
if (!(await deleteVideo(code))) throw new Error();
setVideos((current) => current.filter((video) => video.share_code !== code));
toast.success('Recording deleted');
setConfirmDelete(null);
} catch {
toast.danger('Could not delete this recording. Try again.');
} finally {
setPending(null);
}
}
return (
<>
<Notifications />
<Header title="Library" library />
<main className="library-main">
<div className="recording-heading">
<div>
<h1 className="recording-title">Your recordings</h1>
<p className="recording-meta">Manage your shared videos and links.</p>
</div>
<Button variant="ghost" size="sm" onPress={() => void load()} isDisabled={loading}>
<ArrowClockwiseIcon size={16} />
Refresh
</Button>
</div>
{error && <Notice>{error}</Notice>}
<div className="library-grid">
{loading
? [0, 1, 2].map((i) => <Skeleton key={i} className="loading-sidebar" />)
: videos.map((video) => {
const days = daysUntilExpiry(video.expires_at);
return (
<Card key={video.share_code}>
<img
className="library-thumb"
src={`/thumb/${video.share_code}`}
alt=""
loading="lazy"
onError={(e) => {
e.currentTarget.style.visibility = 'hidden';
}}
/>
<Card.Header>
<Card.Title>{video.title}</Card.Title>
<Card.Description>
{formatDuration(video.duration)} · {formatDate(video.created_at)} ·{' '}
{video.view_count} {video.view_count === 1 ? 'view' : 'views'}
</Card.Description>
</Card.Header>
<Card.Content>
<Chip size="sm" variant="soft" color={days <= 3 ? 'warning' : 'default'}>
{Number.isNaN(days)
? 'Expiry unknown'
: days === 0
? 'Expires today'
: days === 1
? 'Expires tomorrow'
: `Expires in ${days} days`}
</Chip>
{!!video.is_protected && (
<Chip size="sm" variant="soft">
<LockKeyIcon size={12} />
Protected
</Chip>
)}
</Card.Content>
<Card.Footer className="library-actions">
<Link
className="button button--sm button--ghost"
href={`/s/${video.share_code}`}
target="_blank"
rel="noopener noreferrer"
>
<ArrowSquareOutIcon size={16} />
Open
</Link>
<CopyButton code={video.share_code} />
<Button
size="sm"
variant="ghost"
isDisabled={pending !== null}
onPress={() => void renew(video.share_code)}
>
Renew
</Button>
<Button
size="sm"
variant="danger-soft"
isIconOnly
aria-label={`Delete ${video.title}`}
isDisabled={pending !== null}
onPress={() => setConfirmDelete(video.share_code)}
>
<TrashIcon size={16} />
</Button>
</Card.Footer>
{confirmDelete === video.share_code && (
<Card.Content className="gate-content">
<Notice>
Delete “{video.title}”? This permanently removes the recording and its
share link.
</Notice>
<div className="library-actions">
<Button
size="sm"
variant="secondary"
onPress={() => setConfirmDelete(null)}
isDisabled={pending !== null}
>
Cancel
</Button>
<Button
size="sm"
variant="danger"
isPending={pending === video.share_code}
isDisabled={pending !== null}
onPress={() => void remove(video.share_code)}
>
Delete recording
</Button>
</div>
</Card.Content>
)}
</Card>
);
})}
</div>
{!loading && !error && videos.length === 0 && (
<Card className="empty-feedback">
<Card.Title>No shared recordings yet</Card.Title>
<Card.Description>Publish a recording from Recordly to see it here.</Card.Description>
</Card>
)}
</main>
</>
);
const [section, setSection] = useState("shared");
const [query, setQuery] = useState("");
const [period, setPeriod] = useState("all");
const [sort, setSort] = useState("recent");
const [videos, setVideos] = useState<LibraryVideo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string[] | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
setVideos(await fetchVideos());
} catch {
setError("Could not load your recordings. Please try again.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function renew(code: string) {
setPending(code);
try {
const expires = await renewVideo(code);
if (!expires) throw new Error();
setVideos((current) =>
current.map((video) =>
video.share_code === code ? { ...video, expires_at: expires } : video,
),
);
toast.success("Share link renewed");
} catch {
toast.danger("Could not renew this link. Try again.");
} finally {
setPending(null);
}
}
async function remove(code: string) {
setPending(code);
try {
if (!(await deleteVideo(code))) throw new Error();
setVideos((current) => current.filter((video) => video.share_code !== code));
toast.success("Recording deleted");
return true;
} catch {
toast.danger("Could not delete this recording. Try again.");
return false;
} finally {
setPending(null);
}
}
const [folders, setFolders] = useState<LibraryFolder[]>([]);
const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [folderName, setFolderName] = useState("");
const [help, setHelp] = useState(false);
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
try {
const saved: unknown = JSON.parse(
localStorage.getItem("recordly-cloud-folders") || "[]",
);
if (Array.isArray(saved))
setFolders(
saved.filter(
(f): f is LibraryFolder =>
!!f &&
typeof f.id === "string" &&
typeof f.name === "string" &&
Array.isArray(f.codes) &&
f.codes.every((code: unknown) => typeof code === "string"),
),
);
} catch {
/* An unavailable or invalid local cache starts with no folders. */
}
}, []);
function saveFolders(next: LibraryFolder[]) {
setFolders(next);
try {
localStorage.setItem("recordly-cloud-folders", JSON.stringify(next));
} catch {
toast.danger("Folders could not be saved in this browser.");
}
}
function assignFolder(code: string, id: string) {
saveFolders(
folders.map((folder) =>
folder.id === id
? {
...folder,
codes: folder.codes.includes(code)
? folder.codes.filter((item) => item !== code)
: [...folder.codes, code],
}
: folder,
),
);
}
function finishFolder() {
if (folderName.trim())
saveFolders(
folders.map((folder) =>
folder.id === editingFolder ? { ...folder, name: folderName.trim() } : folder,
),
);
setEditingFolder(null);
}
const visible = videos
.filter((video) => {
const age = Date.now() - parseUTC(video.created_at).getTime();
const folder = folders.find((folder) => folder.id === section);
return (
video.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) &&
(!folder || folder.codes.includes(video.share_code)) &&
(period === "all" || age <= (period === "week" ? 7 : 30) * 86400000)
);
})
.sort((a, b) =>
sort === "name"
? a.title.localeCompare(b.title)
: parseUTC(b.created_at).getTime() - parseUTC(a.created_at).getTime(),
);
const busy = pending !== null || deleting;
const navClass = (active: boolean) =>
`h-10 w-full justify-start gap-3 px-3 text-[13px] ${active ? "bg-default/50 font-medium" : "text-muted-foreground"}`;
async function copy(code: string) {
try {
await navigator.clipboard.writeText(`${location.origin}/s/${code}`);
toast.success("Link copied");
} catch {
toast.danger("Could not copy the link.");
}
}
return (
<>
<Notifications />
<div className="library-dashboard flex h-full flex-row gap-0 rounded-none bg-background p-0 text-foreground">
<aside
aria-label="Library navigation"
className="flex w-48 shrink-0 flex-col bg-transparent px-4 pb-5 pt-12 lg:w-56"
>
<div className="mb-4 flex h-10 items-center gap-2.5 px-3">
<img src="/icon-64.png" alt="" className="size-7 rounded-lg" />
<span className="text-[15px] font-semibold tracking-tight">Recordly</span>
</div>
<nav className="space-y-1">
<Button
variant="ghost"
className={navClass(section === "shared")}
aria-current={section === "shared" ? "page" : undefined}
onPress={() => setSection("shared")}
>
<CloudIcon
weight={section === "shared" ? "fill" : "regular"}
className="size-[18px]"
/>
Shared
</Button>
</nav>
<div className="mb-2 mt-9 flex items-center justify-between pl-3">
<h2 className="text-[13px] font-semibold tracking-tight text-foreground/80">
Folders
</h2>
<Button
variant="ghost"
isIconOnly
className="size-7 min-w-7"
aria-label="New folder"
onPress={() => {
let name = "Untitled folder",
index = 1;
while (folders.some((folder) => folder.name === name))
name = `Untitled folder ${index++}`;
const id = crypto.randomUUID();
saveFolders([...folders, { id, name, codes: [] }]);
setEditingFolder(id);
setFolderName(name);
}}
>
<PlusIcon className="size-3.5" />
</Button>
</div>
<div className="library-folders min-h-0 flex-1 space-y-1 overflow-y-auto">
{folders.map((folder) => (
<div
key={folder.id}
className={`group flex h-10 items-center gap-1 rounded-lg px-1 ${section === folder.id ? "bg-default/70" : "hover:bg-default/40"}`}
>
<FolderSimpleIcon
weight="fill"
className="mx-2 size-4 shrink-0 text-accent"
/>
{editingFolder === folder.id ? (
<form
className="min-w-0 flex-1"
onSubmit={(e) => {
e.preventDefault();
finishFolder();
}}
>
<Input
autoFocus
aria-label="Folder name"
maxLength={80}
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
onBlur={finishFolder}
onKeyDown={(e) => {
if (e.key === "Escape") setEditingFolder(null);
}}
className="h-8 w-full min-w-0 border-0 bg-transparent px-0 text-[13px] shadow-none"
/>
</form>
) : (
<Button
variant="ghost"
aria-current={section === folder.id ? "page" : undefined}
onPress={() => setSection(folder.id)}
className="h-full min-w-0 flex-1 justify-start rounded-none px-0 text-[13px]"
>
<span className="truncate">{folder.name}</span>
</Button>
)}
<Dropdown>
<Button
variant="ghost"
isIconOnly
aria-label={`Options for ${folder.name}`}
className="size-6 min-w-6 text-muted-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
>
<DotsThreeIcon className="size-4" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Folder options">
<Dropdown.Item
id="rename"
onAction={() => {
setEditingFolder(folder.id);
setFolderName(folder.name);
}}
>
Rename
</Dropdown.Item>
<Dropdown.Item
id="remove"
onAction={() => {
saveFolders(
folders.filter(
(item) => item.id !== folder.id,
),
);
if (section === folder.id) setSection("shared");
}}
>
Remove folder
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
))}
</div>
<div className="space-y-1 pt-6">
<ThemeToggle sidebar />
<Button
variant="ghost"
className={navClass(section === "settings")}
onPress={() => setSection("settings")}
>
<GearSixIcon
weight={section === "settings" ? "fill" : "regular"}
className="size-[18px]"
/>
Settings
</Button>
<Button
variant="ghost"
className={navClass(false)}
onPress={() => location.assign("/library/logout")}
>
<SignOutIcon className="size-[18px]" />
Log out
</Button>
</div>
</aside>
<Card className="my-3 mr-3 flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl bg-background p-0 shadow-sm">
{section !== "settings" && (
<>
<header className="flex h-24 shrink-0 items-center gap-5 px-7 pt-5 lg:px-10">
<div className="relative min-w-0 flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground/60" />
<Input
aria-label="Search recordings"
placeholder="Search recordings…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-11 w-full border-0 bg-default/30 pl-10 shadow-none"
/>
</div>
</header>
<div className="flex flex-wrap items-center gap-3 px-7 pb-7 lg:px-10">
<div className="flex gap-2" aria-label="Time filters">
{[
["all", "All"],
["week", "Last 7 days"],
["month", "Last 30 days"],
].map(([id, label]) => (
<Button
key={id}
variant={period === id ? "secondary" : "ghost"}
size="sm"
aria-pressed={period === id}
onPress={() => setPeriod(id)}
className={`h-7 rounded-lg px-3 text-xs ${period === id ? "bg-default/60 text-foreground" : "text-muted-foreground"}`}
>
{label}
</Button>
))}
<Button
variant="ghost"
isIconOnly
className="size-7 min-w-7 text-danger"
aria-label="Select recordings to delete"
aria-pressed={selecting}
onPress={() => {
setSelecting(!selecting);
setSelected([]);
}}
>
<TrashIcon weight="fill" className="size-4" />
</Button>
</div>
<div className="ml-auto">
<Dropdown>
<Button
variant="ghost"
size="sm"
aria-label="Sort recordings"
className="h-7 gap-2 text-xs text-muted-foreground"
>
{sort === "name" ? "Name" : "Last created"}
<CaretDownIcon className="size-3" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Sort recordings">
<Dropdown.Item
id="recent"
onAction={() => setSort("recent")}
>
Last created
</Dropdown.Item>
<Dropdown.Item
id="name"
onAction={() => setSort("name")}
>
Name
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</div>
{selecting && (
<div className="flex items-center gap-3 px-7 pb-5 text-xs lg:px-10">
<Button
variant="ghost"
size="sm"
onPress={() =>
setSelected(visible.map((video) => video.share_code))
}
>
Select all
</Button>
<span>{selected.length} selected</span>
<Button
variant="danger"
size="sm"
isDisabled={!selected.length || busy}
onPress={() => setConfirmDelete(selected)}
>
Delete
</Button>
<Button
variant="ghost"
size="sm"
onPress={() => {
setSelecting(false);
setSelected([]);
}}
>
Cancel
</Button>
</div>
)}
</>
)}
<main className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-7 pb-10 lg:px-10">
{section === "settings" ? (
<div className="space-y-5 py-10">
<h1 className="text-lg font-semibold">Library settings</h1>
<p className="text-sm text-muted">
Folders are saved in this browser. Recordings and share links
are stored by your sharing service.
</p>
<Button variant="secondary" onPress={() => setHelp(true)}>
How to share a recording
</Button>
</div>
) : (
<>
{error && <Notice>{error}</Notice>}
<ul
aria-label="Your recordings"
className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
>
{loading
? [0, 1, 2].map((i) => (
<li key={i}>
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
</li>
))
: visible.map((video) => (
<LibraryCard
key={video.share_code}
video={video}
folders={folders}
assignFolder={assignFolder}
selecting={selecting}
selected={selected.includes(video.share_code)}
toggleSelected={() =>
setSelected((current) =>
current.includes(video.share_code)
? current.filter(
(code) =>
code !==
video.share_code,
)
: [...current, video.share_code],
)
}
busy={busy}
copy={() => void copy(video.share_code)}
renew={() => void renew(video.share_code)}
remove={() =>
setConfirmDelete([video.share_code])
}
/>
))}
</ul>
{!loading && !error && !visible.length && (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<CloudIcon weight="fill" className="size-8 opacity-40" />
<p>
{videos.length
? "No recordings match this view."
: "No shared recordings yet."}
</p>
<Button
variant="secondary"
onPress={() => {
setQuery("");
setPeriod("all");
setSection("shared");
}}
>
Show all recordings
</Button>
</div>
)}
</>
)}
</main>
</Card>
</div>
<Modal isOpen={help} onOpenChange={setHelp}>
<Modal.Backdrop>
<Modal.Container>
<Modal.Dialog>
<Modal.Header>
<Modal.Heading>Share a recording</Modal.Heading>
</Modal.Header>
<Modal.Body>
<p>
Open a project in Recordly, choose Export, then Create share
link. Your shared recording will appear here.
</p>
</Modal.Body>
<Modal.Footer>
<Button onPress={() => setHelp(false)}>Got it</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<Modal
isOpen={confirmDelete !== null}
onOpenChange={(open) => {
if (!open && !busy) setConfirmDelete(null);
}}
>
<Modal.Backdrop>
<Modal.Container>
<Modal.Dialog>
<Modal.Header>
<Modal.Heading>
Delete{" "}
{confirmDelete?.length === 1 ? "recording" : "recordings"}?
</Modal.Heading>
</Modal.Header>
<Modal.Body>
<p>
This permanently removes {confirmDelete?.length} recording(s)
and their share links.
</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
isDisabled={busy}
onPress={() => setConfirmDelete(null)}
>
Cancel
</Button>
<Button
variant="danger"
isDisabled={busy}
isPending={deleting}
onPress={async () => {
setDeleting(true);
try {
const failed: string[] = [];
for (const code of confirmDelete || []) {
if (!(await remove(code))) failed.push(code);
}
setConfirmDelete(failed.length ? failed : null);
setSelected(failed);
setSelecting(failed.length > 0);
} finally {
setDeleting(false);
}
}}
>
Delete
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</>
);
}
@@ -1,300 +1,325 @@
import { Avatar, Link, Button, Input, Label, Skeleton, TextField, toast } from '@heroui/react';
import { ChatCircleIcon, LockKeyIcon, ArrowClockwiseIcon } from '@phosphor-icons/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import ThemeToggle from "./ThemeToggle";
import { Avatar, Button, Input, Label, Skeleton, TextField, toast } from "@heroui/react";
import { ChatCircleIcon, LockKeyIcon, ArrowClockwiseIcon } from "@phosphor-icons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchShareData,
fetchReactions,
postReaction,
verifyPassword,
isShareData,
isPasswordRequired,
isExpired,
formatDate,
formatTimestamp,
type ShareData,
type Comment,
type Reaction,
} from '../scripts/api';
import SharePlayer from './SharePlayer';
import ShareFeedback from './ShareFeedback';
import { CenteredCard, Header, Notice, CopyButton, Notifications } from './ShareUI';
fetchShareData,
fetchReactions,
postReaction,
verifyPassword,
isShareData,
isPasswordRequired,
isExpired,
formatDate,
formatTimestamp,
type ShareData,
type Comment,
type Reaction,
} from "../scripts/api";
import SharePlayer from "./SharePlayer";
import ShareFeedback from "./ShareFeedback";
import { CenteredCard, Notice, CopyButton, Notifications } from "./ShareUI";
const reactions = [
{ emoji: '👍', label: 'Like' },
{ emoji: '❤️', label: 'Love' },
{ emoji: '😂', label: 'Laugh' },
{ emoji: '😮', label: 'Surprised' },
{ emoji: '🔥', label: 'Fire' },
{ emoji: '👏', label: 'Applause' },
{ emoji: "👍", label: "Like" },
{ emoji: "❤️", label: "Love" },
{ emoji: "😂", label: "Laugh" },
{ emoji: "😮", label: "Surprised" },
{ emoji: "🔥", label: "Fire" },
{ emoji: "👏", label: "Applause" },
];
function Recording({ data }: { data: ShareData }) {
const videoRef = useRef<HTMLVideoElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(data.video.duration);
const [comments, setComments] = useState<Comment[]>([]);
const [reactionEvents, setReactionEvents] = useState<Reaction[]>([]);
const counts = reactionEvents.reduce<Record<string, number>>((result, item) => {
result[item.emoji] = (result[item.emoji] || 0) + 1;
return result;
}, {});
const [reacting, setReacting] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState('comments');
const [mobileView, setMobileView] = useState('watch');
const seek = useCallback((value: number) => {
const video = videoRef.current;
if (video && Number.isFinite(video.duration)) {
video.currentTime = Math.max(0, Math.min(video.duration, value));
setTime(video.currentTime);
}
}, []);
const videoRef = useRef<HTMLVideoElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(data.video.duration);
const [comments, setComments] = useState<Comment[]>([]);
const [reactionEvents, setReactionEvents] = useState<Reaction[]>([]);
const counts = reactionEvents.reduce<Record<string, number>>((result, item) => {
result[item.emoji] = (result[item.emoji] || 0) + 1;
return result;
}, {});
const [reacting, setReacting] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState("comments");
const [mobileView, setMobileView] = useState("watch");
const seek = useCallback((value: number) => {
const video = videoRef.current;
if (video && Number.isFinite(video.duration)) {
video.currentTime = Math.max(0, Math.min(video.duration, value));
setTime(video.currentTime);
}
}, []);
useEffect(() => {
let cancelled = false;
fetchReactions(data.shareCode)
.then((items) => {
if (!cancelled) setReactionEvents((current) => [...items, ...current]);
})
.catch(() => {
/* Feedback remains available if counts fail to load. */
});
return () => {
cancelled = true;
};
}, [data.shareCode]);
useEffect(() => {
let cancelled = false;
fetchReactions(data.shareCode)
.then((items) => {
if (!cancelled) setReactionEvents((current) => [...items, ...current]);
})
.catch(() => {
/* Feedback remains available if counts fail to load. */
});
return () => {
cancelled = true;
};
}, [data.shareCode]);
async function react(emoji: string) {
if (reacting) return;
setReacting(emoji);
const timestamp = videoRef.current?.currentTime || 0;
try {
if (!(await postReaction(data.shareCode, timestamp, emoji)))
throw new Error();
setReactionEvents((current) => [...current, { timestamp, emoji, created_at: new Date().toISOString() }]);
toast.success(`${emoji} Reaction at ${formatTimestamp(timestamp)}`);
} catch {
toast.danger('Could not add your reaction. Try again.');
} finally {
setReacting(null);
}
}
async function react(emoji: string) {
if (reacting) return;
setReacting(emoji);
const timestamp = videoRef.current?.currentTime || 0;
try {
if (!(await postReaction(data.shareCode, timestamp, emoji))) throw new Error();
setReactionEvents((current) => [
...current,
{ timestamp, emoji, created_at: new Date().toISOString() },
]);
toast.success(`${emoji} Reaction at ${formatTimestamp(timestamp)}`);
} catch {
toast.danger("Could not add your reaction. Try again.");
} finally {
setReacting(null);
}
}
const { video } = data;
return (
<div className="recording-app" data-mobile-view={mobileView}>
<Header />
{mobileView === 'feedback' && (
<div className="mobile-feedback-back">
<Button size="sm" variant="secondary" onPress={() => setMobileView('watch')}>Back to video</Button>
</div>
)}
<div className="share-workspace">
<main className="recording-column">
<div className="recording-heading">
<div>
<h1 className="recording-title">{video.title}</h1>
<div className="creator-info" aria-label="Recording creator">
<Avatar size="sm"><Avatar.Fallback>{data.creator?.name.slice(0, 2).toUpperCase() || '?'}</Avatar.Fallback></Avatar>
<div>
<div className="creator-name">
{data.creator?.website && /^https?:\/\//i.test(data.creator.website) ? <Link href={data.creator.website} target="_blank" rel="noopener noreferrer">{data.creator.name}</Link> : data.creator?.name || 'Creator not provided'}
</div>
{data.creator?.bio && <div className="creator-bio" title={data.creator.bio}>{data.creator.bio}</div>}
</div>
</div>
<div className="recording-meta">
<span>{formatDate(video.created_at)}</span>
<span>
{video.view_count.toLocaleString()} {video.view_count === 1 ? 'view' : 'views'}
</span>
{video.width > 0 && (
<span>
{video.width} × {video.height}
</span>
)}
</div>
</div>
<CopyButton time={time} />
</div>
<SharePlayer
data={data}
videoRef={videoRef}
comments={comments}
reactions={reactionEvents}
time={time}
duration={duration}
onTime={setTime}
onDuration={setDuration}
seek={seek}
/>
<div className="reaction-rail">
<div className="reaction-buttons" aria-label="Reactions">
{reactions.map(({ emoji, label }) => (
<Button
key={emoji}
size="sm"
variant="ghost"
aria-label={`${label}${counts[emoji] ? `, ${counts[emoji]} ${counts[emoji] === 1 ? 'reaction' : 'reactions'}` : ''}`}
isDisabled={reacting !== null}
onPress={() => void react(emoji)}
>
<span aria-hidden="true">{emoji}</span>
{counts[emoji] > 0 && <span>{counts[emoji]}</span>}
</Button>
))}
</div>
<Button
className="mobile-comment-button"
size="sm"
variant="primary"
onPress={() => {
setSelectedTab('comments');
setMobileView('feedback');
requestAnimationFrame(() => composerRef.current?.focus({ preventScroll: true }));
}}
>
<ChatCircleIcon size={17} />
Comment
</Button>
</div>
</main>
<aside aria-label="Recording feedback">
<ShareFeedback
data={data}
time={time}
duration={duration}
comments={comments}
setComments={setComments}
seek={seek}
composerRef={composerRef}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
</aside>
</div>
</div>
);
const { video } = data;
return (
<div className="recording-app" data-mobile-view={mobileView}>
{mobileView === "feedback" && (
<div className="mobile-feedback-back">
<Button size="sm" variant="secondary" onPress={() => setMobileView("watch")}>
Back to video
</Button>
</div>
)}
<div className="share-workspace">
<main className="recording-column">
<div className="recording-heading viewer-heading">
<div className="viewer-identity">
<Avatar
aria-label={data.creator?.name || "Recording creator"}
className="!size-[48px] shrink-0 !rounded-full bg-accent/15 text-accent"
>
<Avatar.Fallback className="!rounded-full bg-accent/15 text-xs font-medium text-accent">
{data.creator?.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase() || "?"}
</Avatar.Fallback>
</Avatar>
<div className="viewer-caption">
<h1 className="recording-title" title={video.title}>
{video.title}
</h1>
<div className="recording-meta">
<span>{formatDate(video.created_at)}</span>
<span>
{video.view_count.toLocaleString()}{" "}
{video.view_count === 1 ? "view" : "views"}
</span>
</div>
</div>
</div>
<div className="viewer-heading-actions">
<ThemeToggle />
<CopyButton time={time} />
</div>
</div>
<SharePlayer
data={data}
videoRef={videoRef}
comments={comments}
reactions={reactionEvents}
time={time}
duration={duration}
onTime={setTime}
onDuration={setDuration}
seek={seek}
/>
<div className="reaction-rail">
<div className="reaction-buttons" aria-label="Reactions">
{reactions.map(({ emoji, label }) => (
<Button
key={emoji}
size="sm"
variant="ghost"
aria-label={`${label}${counts[emoji] ? `, ${counts[emoji]} ${counts[emoji] === 1 ? "reaction" : "reactions"}` : ""}`}
isDisabled={reacting !== null}
onPress={() => void react(emoji)}
>
<span aria-hidden="true">{emoji}</span>
{counts[emoji] > 0 && <span>{counts[emoji]}</span>}
</Button>
))}
</div>
<Button
className="mobile-comment-button"
size="sm"
variant="primary"
onPress={() => {
setSelectedTab("comments");
setMobileView("feedback");
requestAnimationFrame(() =>
composerRef.current?.focus({ preventScroll: true }),
);
}}
>
<ChatCircleIcon size={17} />
Comment
</Button>
</div>
</main>
<aside aria-label="Recording feedback">
<ShareFeedback
data={data}
time={time}
duration={duration}
comments={comments}
setComments={setComments}
seek={seek}
composerRef={composerRef}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
</aside>
</div>
</div>
);
}
export default function SharePage() {
const [data, setData] = useState<ShareData | null>(null);
const [view, setView] = useState<'loading' | 'password' | 'expired' | 'error' | 'recording'>(
'loading',
);
const [title, setTitle] = useState('Protected recording');
const [password, setPassword] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState<ShareData | null>(null);
const [view, setView] = useState<"loading" | "password" | "expired" | "error" | "recording">(
"loading",
);
const [title, setTitle] = useState("Protected recording");
const [password, setPassword] = useState("");
const [unlocking, setUnlocking] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setView('loading');
try {
const code = location.pathname.split('/').filter(Boolean).pop() || '';
const result = await fetchShareData(code);
if (isExpired(result)) {
setView('expired');
document.title = 'Recording unavailable — Recordly';
} else if (isPasswordRequired(result)) {
setView('password');
setTitle(result.title);
document.title = `${result.title} — Recordly`;
} else if (isShareData(result)) {
setData(result);
setView('recording');
document.title = `${result.video.title} — Recordly`;
} else setView('error');
} catch {
setView('error');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const load = useCallback(async () => {
setView("loading");
try {
const code = location.pathname.split("/").filter(Boolean).pop() || "";
const result = await fetchShareData(code);
if (isExpired(result)) {
setView("expired");
document.title = "Recording unavailable — Recordly";
} else if (isPasswordRequired(result)) {
setView("password");
setTitle(result.title);
document.title = `${result.title} — Recordly`;
} else if (isShareData(result)) {
setData(result);
setView("recording");
document.title = `${result.video.title} — Recordly`;
} else setView("error");
} catch {
setView("error");
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function unlock() {
if (unlocking) return;
setUnlocking(true);
setError('');
try {
const code = location.pathname.split('/').filter(Boolean).pop() || '';
if (await verifyPassword(code, password)) await load();
else setError('That password didn’t match. Please try again.');
} catch {
setError('Could not unlock the recording. Please try again.');
} finally {
setUnlocking(false);
}
}
async function unlock() {
if (unlocking) return;
setUnlocking(true);
setError("");
try {
const code = location.pathname.split("/").filter(Boolean).pop() || "";
if (await verifyPassword(code, password)) await load();
else setError("That password didn’t match. Please try again.");
} catch {
setError("Could not unlock the recording. Please try again.");
} finally {
setUnlocking(false);
}
}
return (
<>
<Notifications />
{view === 'loading' ? (
<div className="recording-app">
<Header />
<div className="share-workspace" aria-label="Loading recording" aria-busy="true">
<div className="recording-column">
<div>
<Skeleton className="loading-title" />
<Skeleton className="loading-meta" />
</div>
<Skeleton className="loading-video" />
</div>
<Skeleton className="loading-sidebar" />
</div>
</div>
) : view === 'recording' && data ? (
<Recording data={data} />
) : (
<CenteredCard>
{view === 'password' ? (
<>
<LockKeyIcon size={28} />
<h1>{title}</h1>
<p>This recording is password protected.</p>
<form
className="gate-form"
onSubmit={(e) => {
e.preventDefault();
void unlock();
}}
>
<TextField
value={password}
onChange={setPassword}
isRequired
isDisabled={unlocking}
>
<Label>Password</Label>
<Input type="password" autoComplete="current-password" autoFocus />
</TextField>
{error && <Notice>{error}</Notice>}
<Button type="submit" isPending={unlocking} isDisabled={!password || unlocking}>
{unlocking ? 'Unlocking…' : 'Unlock recording'}
</Button>
</form>
</>
) : view === 'expired' ? (
<>
<h1>Recording unavailable</h1>
<p>
This link has expired or the recording has been removed. Ask the creator for a new
link.
</p>
</>
) : (
<>
<h1>Couldn’t load the recording</h1>
<p>Check your connection and try again.</p>
<Button onPress={() => void load()}>
<ArrowClockwiseIcon size={16} />
Try again
</Button>
</>
)}
</CenteredCard>
)}
</>
);
return (
<>
<Notifications />
{view === "loading" ? (
<div className="recording-app">
<div
className="share-workspace"
aria-label="Loading recording"
aria-busy="true"
>
<div className="recording-column">
<div>
<Skeleton className="loading-title" />
<Skeleton className="loading-meta" />
</div>
<Skeleton className="loading-video" />
</div>
<Skeleton className="loading-sidebar" />
</div>
</div>
) : view === "recording" && data ? (
<Recording data={data} />
) : (
<CenteredCard>
{view === "password" ? (
<>
<LockKeyIcon size={28} />
<h1>{title}</h1>
<p>This recording is password protected.</p>
<form
className="gate-form"
onSubmit={(e) => {
e.preventDefault();
void unlock();
}}
>
<TextField
value={password}
onChange={setPassword}
isRequired
isDisabled={unlocking}
>
<Label>Password</Label>
<Input
type="password"
autoComplete="current-password"
autoFocus
/>
</TextField>
{error && <Notice>{error}</Notice>}
<Button
type="submit"
isPending={unlocking}
isDisabled={!password || unlocking}
>
{unlocking ? "Unlocking…" : "Unlock recording"}
</Button>
</form>
</>
) : view === "expired" ? (
<>
<h1>Recording unavailable</h1>
<p>
This link has expired or the recording has been removed. Ask the
creator for a new link.
</p>
</>
) : (
<>
<h1>Couldn’t load the recording</h1>
<p>Check your connection and try again.</p>
<Button onPress={() => void load()}>
<ArrowClockwiseIcon size={16} />
Try again
</Button>
</>
)}
</CenteredCard>
)}
</>
);
}
@@ -1,88 +1,67 @@
import { Alert, Button, Card, Link, Toast, toast } from '@heroui/react';
import { ArrowUpRightIcon, LinkSimpleIcon, WarningCircleIcon } from '@phosphor-icons/react';
import type { ReactNode } from 'react';
import { Alert, Button, Card, Link, Toast, toast } from "@heroui/react";
import { LinkSimpleIcon, WarningCircleIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
export function Brand() {
return (
<Link
href="https://github.com/webadderallorg/Recordly"
target="_blank"
rel="noopener noreferrer"
className="brand"
>
<img src="/icon-64.png" alt="" width="28" height="28" />
<span>Recordly</span>
</Link>
);
}
export function Header({ title, library = false }: { title?: string; library?: boolean }) {
return (
<header className="share-header">
<div className="header-start">
<Brand />
<span className="header-divider" />
<span className="header-context">{title || 'Shared recording'}</span>
</div>
<Link
className="button button--sm button--secondary"
href={library ? '/library/logout' : 'https://github.com/webadderallorg/Recordly'}
target={library ? undefined : '_blank'}
rel={library ? undefined : 'noopener noreferrer'}
>
{library ? 'Log out' : 'Get Recordly'}
{!library && <ArrowUpRightIcon size={16} />}
</Link>
</header>
);
return (
<Link
href="https://github.com/webadderallorg/Recordly"
target="_blank"
rel="noopener noreferrer"
className="brand"
>
<img src="/icon-64.png" alt="" width="28" height="28" />
<span>Recordly</span>
</Link>
);
}
export function Notice({ children }: { children: ReactNode }) {
return (
<Alert status="danger" role="alert">
<Alert.Indicator>
<WarningCircleIcon />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{children}</Alert.Description>
</Alert.Content>
</Alert>
);
return (
<Alert status="danger" role="alert">
<Alert.Indicator>
<WarningCircleIcon />
</Alert.Indicator>
<Alert.Content>
<Alert.Description>{children}</Alert.Description>
</Alert.Content>
</Alert>
);
}
export function CenteredCard({ children }: { children: ReactNode }) {
return (
<main className="gate-page">
<Card className="gate-card">
<Card.Header>
<Brand />
</Card.Header>
<Card.Content className="gate-content">{children}</Card.Content>
</Card>
</main>
);
return (
<main className="gate-page">
<Card className="gate-card">
<Card.Header>
<Brand />
</Card.Header>
<Card.Content className="gate-content">{children}</Card.Content>
</Card>
</main>
);
}
export async function copyLink(time = 0, code?: string) {
const url = new URL(code ? `/s/${code}` : window.location.pathname, window.location.origin);
if (time > 0) url.searchParams.set('t', String(Math.floor(time)));
try {
await navigator.clipboard.writeText(url.href);
toast.success('Link copied');
} catch {
toast.danger('Could not copy the link. Copy the address from your browser.');
}
const url = new URL(code ? `/s/${code}` : window.location.pathname, window.location.origin);
if (time > 0) url.searchParams.set("t", String(Math.floor(time)));
try {
await navigator.clipboard.writeText(url.href);
toast.success("Link copied");
} catch {
toast.danger("Could not copy the link. Copy the address from your browser.");
}
}
export function CopyButton({ time = 0, code }: { time?: number; code?: string }) {
return (
<Button size="sm" variant="secondary" onPress={() => void copyLink(time, code)}>
<LinkSimpleIcon size={16} />
Copy link
</Button>
);
return (
<Button size="sm" variant="secondary" onPress={() => void copyLink(time, code)}>
<LinkSimpleIcon size={16} />
Copy link
</Button>
);
}
export function Notifications() {
return <Toast.Provider placement="bottom" />;
return <Toast.Provider placement="bottom" />;
}
@@ -0,0 +1,44 @@
import { Button } from "@heroui/react";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
export default function ThemeToggle({ sidebar = false }: { sidebar?: boolean }) {
const [dark, setDark] = useState(false);
useEffect(() => {
const update = () => setDark(document.documentElement.dataset.theme === "dark");
update();
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => observer.disconnect();
}, []);
const label = dark ? "Light mode" : "Dark mode";
return (
<Button
variant="ghost"
isIconOnly={!sidebar}
aria-label={`Switch to ${label.toLowerCase()}`}
className={
sidebar
? "h-10 w-full justify-start gap-3 px-3 text-[13px] text-muted-foreground"
: "size-9 min-w-9 shrink-0"
}
onPress={() => {
const theme = dark ? "light" : "dark";
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
try {
localStorage.setItem("recordly.theme", theme);
} catch {
/* Theme still works without storage. */
}
}}
>
{dark ? <SunIcon className="size-[18px]" /> : <MoonIcon className="size-[18px]" />}
{sidebar && label}
</Button>
);
}
@@ -0,0 +1,187 @@
import { Avatar, Button, Dropdown } from "@heroui/react";
import {
CheckIcon,
DotsThreeIcon,
FolderSimpleIcon,
ImageSquareIcon,
PlusIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { parseUTC, type LibraryVideo } from "../../scripts/library";
export type LibraryFolder = { id: string; name: string; codes: string[] };
export function LibraryCard({
video,
folders,
assignFolder,
selecting,
selected,
toggleSelected,
busy,
renew,
remove,
copy,
}: {
video: LibraryVideo;
folders: LibraryFolder[];
assignFolder: (code: string, id: string) => void;
selecting: boolean;
selected: boolean;
toggleSelected: () => void;
busy: boolean;
renew: () => void;
remove: () => void;
copy: () => void;
}) {
const [failed, setFailed] = useState(false);
const assigned = folders.filter((folder) => folder.codes.includes(video.share_code));
return (
<li className="group min-w-0">
<Button
variant="ghost"
isDisabled={busy}
aria-label={video.title}
onPress={() =>
selecting
? toggleSelected()
: window.open(`/s/${video.share_code}`, "_blank", "noopener,noreferrer")
}
aria-pressed={selecting ? selected : undefined}
className="relative block h-auto w-full min-w-0 rounded-xl p-0"
>
<div className="relative flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-xl bg-default/60">
{!failed ? (
<img
src={`/thumb/${video.share_code}`}
alt=""
loading="lazy"
draggable={false}
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
) : (
<ImageSquareIcon
weight="fill"
className="size-8 text-muted-foreground/20"
/>
)}
</div>
{selecting && (
<span
className={`absolute right-2 top-2 flex size-5 items-center justify-center rounded-md ${selected ? "bg-accent text-white" : "bg-background/90"}`}
>
{selected && <CheckIcon className="size-3.5" />}
</span>
)}
</Button>
<div className="relative flex items-start justify-between gap-4 pt-5">
<Avatar
aria-label="Your library"
className="!size-[48px] shrink-0 !rounded-full bg-accent/15 text-accent"
>
<Avatar.Fallback className="!rounded-full bg-accent/15 text-xs font-medium text-accent">
LP
</Avatar.Fallback>
</Avatar>
<div className="h-12 min-w-0 flex-1">
<p
title={video.title}
className="truncate pr-8 text-[12px] font-medium leading-5"
>
{video.title}
</p>
<div className="mt-1 flex h-6 min-w-0 items-center gap-2">
<p className="shrink-0 text-[11px] text-muted-foreground">
{parseUTC(video.created_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</p>
{assigned.map((folder) => (
<button
key={folder.id}
title={`Remove from ${folder.name}`}
onClick={() => assignFolder(video.share_code, folder.id)}
className="max-w-24 truncate rounded-full bg-default/60 px-2 py-1 text-[11px]"
>
{folder.name}
</button>
))}
<Dropdown>
<Button
variant="ghost"
size="sm"
aria-label={`Add folder to ${video.title}`}
className="h-6 min-w-0 shrink-0 gap-1.5 rounded-full px-2.5 text-[11px] text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
>
<PlusIcon className="size-3" />
Add folder
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Assign recording folder">
{folders.map((folder) => (
<Dropdown.Item
key={folder.id}
id={folder.id}
textValue={folder.name}
onAction={() =>
assignFolder(video.share_code, folder.id)
}
>
<FolderSimpleIcon weight="fill" />
{folder.name}
{folder.codes.includes(video.share_code) && (
<CheckIcon className="size-3" />
)}
</Dropdown.Item>
))}
{!folders.length && (
<Dropdown.Item id="empty" isDisabled>
Create a folder in the sidebar
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</div>
<Dropdown>
<Button
variant="ghost"
isIconOnly
aria-label={`Options for ${video.title}`}
isDisabled={busy}
className="absolute right-0 top-5 size-6 min-w-6 text-muted-foreground"
>
<DotsThreeIcon weight="bold" className="size-5" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Recording options">
<Dropdown.Item
id="open"
onAction={() =>
window.open(
`/s/${video.share_code}`,
"_blank",
"noopener,noreferrer",
)
}
>
Open recording
</Dropdown.Item>
<Dropdown.Item id="copy" onAction={copy}>
Copy link
</Dropdown.Item>
<Dropdown.Item id="renew" onAction={renew}>
Renew share link
</Dropdown.Item>
<Dropdown.Item id="delete" onAction={remove} className="text-danger">
Delete recording
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</li>
);
}
@@ -7,11 +7,32 @@ import '../styles/global.css';
---
<!DOCTYPE html>
<html lang="en" class="dark">
<html lang="en" >
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="light dark">
<script is:inline>
(() => {
const media = matchMedia('(prefers-color-scheme: dark)');
let preference = 'system';
try { preference = localStorage.getItem('recordly.theme') || 'system'; } catch {}
const apply = () => {
const theme = preference === 'light' || preference === 'dark' ? preference : media.matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
};
apply();
media.addEventListener('change', () => {
try { preference = localStorage.getItem('recordly.theme') || 'system'; } catch {}
apply();
});
window.addEventListener('storage', event => {
if (event.key === 'recordly.theme' || event.key === null) { preference = event.newValue || 'system'; apply(); }
});
})();
</script>
<title>{Astro.props.title}</title>
<slot name="head" />
</head>
@@ -1,16 +1,24 @@
@import "tailwindcss";
@import "@heroui/styles";
/* Shared with the desktop HeroUI overhaul: default HeroUI tokens and system type. */
@font-face {
font-family: 'DM Sans';
src: url('/fonts/DMSans-Variable.ttf') format('truetype');
font-weight: 100 1000;
font-style: normal;
font-display: swap;
}
@theme { --font-sans: 'DM Sans', sans-serif; }
/* Match the desktop dashboard typography. */
@layer base {
:root {
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
"DM Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html {
background: var(--background);
color: var(--foreground);
color-scheme: dark;
color-scheme: light dark;
}
body {
margin: 0;
@@ -433,28 +441,6 @@
padding: 0 3px;
font-size: 10px;
}
.library-main {
max-width: 1400px;
margin: auto;
padding: 28px;
}
.library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
gap: 20px;
}
.library-thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--surface-secondary);
border-radius: var(--radius);
}
.library-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -463,14 +449,6 @@
}
}
.library-main > .recording-heading {
margin-bottom: 24px;
}
.share-header > .link,
.library-actions > .link {
gap: 8px;
}
.recording-app {
height: 100dvh;
display: flex;
@@ -708,3 +686,29 @@
.timeline-markers { height: 24px; flex-shrink: 0; }
.timeline-markers .timeline-marker { height: 24px; min-width: 24px; font-size: 12px; }
/* Same theme aliases and shell treatment as the desktop dashboard. */
@theme inline { --color-muted-foreground: var(--muted); }
.library-dashboard { height:100dvh; font-family:'DM Sans',sans-serif; }
.dark .library-dashboard > .card { background:color-mix(in srgb,var(--background) 92%,var(--foreground) 8%); }
.library-dashboard .custom-scrollbar { scrollbar-width:thin; }
@media(max-width:640px) {
.library-dashboard { flex-direction:column; height:auto; min-height:100dvh; }
.library-dashboard > aside { width:100%; padding:16px; }
.library-dashboard > aside > nav { display:flex; }
.library-dashboard > aside .library-folders { max-height:160px; }
.library-dashboard > .card { margin:0 8px 8px; min-height:65dvh; }
}
/* Desktop dashboard shell uses the same HeroUI light/dark tokens. */
.library-dashboard { background:color-mix(in srgb,var(--background) 92%,var(--foreground) 8%); }
.dark .library-dashboard { background:var(--background); }
.library-dashboard > .card { background:var(--background); }
.dark .library-dashboard > .card { background:color-mix(in srgb,var(--background) 92%,var(--foreground) 8%); }
.recording-app .viewer-heading { min-height:48px; gap:16px; }
.viewer-identity { display:flex; align-items:flex-start; gap:16px; min-width:0; flex:1; }
.viewer-caption { height:48px; min-width:0; flex:1; }
.recording-app .viewer-heading .recording-title { display:block; font-size:12px; font-weight:500; line-height:20px; letter-spacing:normal; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.recording-app .viewer-heading .recording-meta { margin-top:4px; height:24px; font-size:11px; gap:8px; white-space:nowrap; }
.viewer-heading-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
@media(max-width:480px) { .recording-app .viewer-heading { flex-wrap:wrap; } .viewer-identity { flex-basis:100%; } .viewer-heading-actions { margin-left:auto; } }