mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
Align shared recording library and viewer with dashboard styling
This commit is contained in:
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
@@ -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.
@@ -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.
|
||||
+21
-1
File diff suppressed because one or more lines are too long
+21
-1
File diff suppressed because one or more lines are too long
+21
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user