switch to daisyui

This commit is contained in:
Jake Walker 2025-07-24 17:54:24 +01:00
parent e38edc45fa
commit 80d81712c7
No known key found for this signature in database
22 changed files with 1175 additions and 439 deletions

View file

@ -13,12 +13,17 @@
"@hey-api/client-fetch": "^0.7.0", "@hey-api/client-fetch": "^0.7.0",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@soorria/solid-dropzone": "^1.0.1", "@soorria/solid-dropzone": "^1.0.1",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/solid-query": "^5.64.1", "@tanstack/solid-query": "^5.64.1",
"bulma": "^1.0.3", "bulma": "^1.0.3",
"daisyui": "^5.0.46",
"huge-uploader": "^1.0.6", "huge-uploader": "^1.0.6",
"lucide-solid": "^0.473.0", "lucide-solid": "^0.473.0",
"node-vibrant": "^4.0.3",
"oidc-client-ts": "^3.1.0", "oidc-client-ts": "^3.1.0",
"solid-js": "^1.9.3" "solid-js": "^1.9.3",
"tailwindcss": "^4.1.11"
}, },
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "^0.62.1", "@hey-api/openapi-ts": "^0.62.1",

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,51 @@
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { children, createSignal, ErrorBoundary, ParentProps, Suspense } from "solid-js"; import { children, ErrorBoundary, ParentProps, Suspense } from "solid-js";
import { readUserOptions } from "./client/@tanstack/solid-query.gen"; import { readUserOptions } from "./client/@tanstack/solid-query.gen";
import { logout } from "./auth"; import { logout } from "./auth";
import { Menu } from "lucide-solid";
export default function AdminLayout(props: ParentProps) { export default function AdminLayout(props: ParentProps) {
const content = children(() => props.children); const content = children(() => props.children);
const [navbarToggled, setNavbarToggled] = createSignal(false);
const userQuery = createQuery(() => ({ const userQuery = createQuery(() => ({
...readUserOptions(), ...readUserOptions(),
})); }));
return <> return <>
<nav class="navbar" role="navigation" aria-label="main navigation"> <div class="navbar bg-primary text-primary-content shadow-sm">
<div class="navbar-brand">
<a class="navbar-item" href="/admin">
<b>Podcast Server</b>
</a>
<a role="button" class="navbar-burger" classList={{ "is-active": navbarToggled() }} aria-label="menu" aria-expanded="false" onClick={() => setNavbarToggled(!navbarToggled())}>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu is-active" classList={{ "is-active": navbarToggled() }}>
<div class="navbar-start"> <div class="navbar-start">
<A class="navbar-item" href="/admin"> <div class="dropdown">
Podcasts <div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
</A> <Menu class="h-5 w-5" />
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow text-base-content">
<li><A href="/admin">Podcasts</A></li>
</ul>
</div>
<A class="btn btn-ghost text-xl" href="/admin">Podcast Admin</A>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><A href="/admin">Podcasts</A></li>
</ul>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<ErrorBoundary fallback={() => <p class="navbar-item">Error</p>}> <div class="flex flex-row gap-2 items-center">
<Suspense fallback={<p class="navbar-item">Loading...</p>}> <ErrorBoundary fallback={() => <p>Error</p>}>
<p class="navbar-item">Hey {userQuery.data?.user_name.split(" ")[0]}!</p> <Suspense fallback={<p>Loading...</p>}>
<p>Hey {userQuery.data?.user_name.split(" ")[0]}!</p>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
<div class="navbar-item"> <a class="btn" onClick={logout}>
<a class="button is-light" onClick={logout}>
Log out Log out
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</nav> <main class="h-100">
<main>
{content()} {content()}
</main> </main>
</>; </>;

View file

@ -57,6 +57,7 @@ export type PodcastPublic = {
explicit?: boolean; explicit?: boolean;
id: string; id: string;
image_filename?: string | null; image_filename?: string | null;
episode_count: number;
}; };
export type PodcastUpdate = { export type PodcastUpdate = {

View file

@ -1,6 +1,7 @@
import { createContext, createEffect, createSignal, ParentComponent } from "solid-js"; import { createContext, createEffect, createSignal, ParentComponent } from "solid-js";
import { fetchUser, login, logout, user } from "../auth"; import { fetchUser, login, logout, user } from "../auth";
import { User } from "oidc-client-ts"; import { User } from "oidc-client-ts";
import Loading from "./loading";
interface AuthContextType { interface AuthContextType {
user: () => User | null; user: () => User | null;
@ -20,7 +21,7 @@ const AuthProvider: ParentComponent = (props) => {
return ( return (
<AuthContext.Provider value={{ user, login, logout }}> <AuthContext.Provider value={{ user, login, logout }}>
{isLoading() ? <p>Loading...</p> : props.children} {isLoading() ? <Loading /> : props.children}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }

View file

@ -1,5 +1,5 @@
import { createDropzone } from "@soorria/solid-dropzone"; import { createDropzone } from "@soorria/solid-dropzone";
import { UploadIcon } from "lucide-solid"; import { Info, UploadIcon } from "lucide-solid";
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { createMutation, useQueryClient } from "@tanstack/solid-query"; import { createMutation, useQueryClient } from "@tanstack/solid-query";
import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen"; import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen";
@ -50,15 +50,16 @@ export default function UploadEpisodeAdditional(props: { podcastId: string, epis
return <> return <>
<Show when={statusText()}> <Show when={statusText()}>
<div class="notification is-info"> <div role="alert" class="alert">
{statusText()} <Info class="stroke-primary h-6 w-6 shrink-0" />
<p>{statusText()}</p>
</div> </div>
</Show> </Show>
<Show when={!uploading()}> <Show when={!uploading()}>
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6"> <div {...dropzone.getRootProps()} class="bg-base-200 rounded-lg shadow-sm flex flex-col items-center p-6 mb-6">
<input {...dropzone.getInputProps()} /> <input {...dropzone.getInputProps()} />
<UploadIcon size="42" /> <UploadIcon size="42" />
<p class="mt-4 has-text-centered"> <p class="mt-4 text-center">
{dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"} {dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"}
</p> </p>
</div> </div>

View file

@ -1,14 +1,18 @@
import { XCircle } from "lucide-solid";
export default function Error(props: { message?: any, reset?: () => void }) { export default function Error(props: { message?: any, reset?: () => void }) {
return ( return (
<section class="section"> <div class="container mx-auto">
<div class="container"> <div role="alert" class="alert alert-error alert-vertical sm:alert-horizontal">
<h1 class="title">Uh Oh!</h1> <XCircle class="h-6 w-6 shrink-0 stroke-current" />
<h2 class="subtitle">Something has gone wrong.</h2> <div>
<h3 class="font-bold">Uh Oh! Something has gone wrong.</h3>
{props.message && <> {props.message && <>
<p class="mb-4">Error: <code>{props.message.toString()}</code></p> <div class="text-xs">Error: <code>{props.message.toString()}</code></div>
</>} </>}
{props.reset && <button class="button" onClick={props.reset}>Retry</button>}
</div> </div>
</section> {props.reset && <button class="btn btn-sm" onClick={props.reset}>Retry</button>}
</div>
</div>
); );
} }

View file

@ -1,10 +1,10 @@
export default function Loading() { export default function Loading(props: { loadingText?: string }) {
return ( return (
<section class="section"> <div class="container mx-auto h-full">
<div class="container"> <div class="w-full h-full flex items-center justify-center gap-2">
<h1 class="title">One moment...</h1> <span class="loading loading-spinner"></span>
<progress class="progress is-primary" max="100" /> <p>{props.loadingText || "One moment..."}</p>
</div>
</div> </div>
</section>
); );
} }

View file

@ -1,5 +1,5 @@
import { createDropzone } from "@soorria/solid-dropzone"; import { createDropzone } from "@soorria/solid-dropzone";
import { UploadIcon } from "lucide-solid"; import { Info, UploadIcon } from "lucide-solid";
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { createMutation, useQueryClient } from "@tanstack/solid-query"; import { createMutation, useQueryClient } from "@tanstack/solid-query";
import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen"; import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen";
@ -49,15 +49,16 @@ export default function UploadImage(props: { podcastId: string }) {
return <> return <>
<Show when={statusText()}> <Show when={statusText()}>
<div class="notification is-info"> <div role="alert" class="alert">
{statusText()} <Info class="stroke-primary h-6 w-6 shrink-0" />
<p>{statusText()}</p>
</div> </div>
</Show> </Show>
<Show when={!uploading()}> <Show when={!uploading()}>
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6"> <div {...dropzone.getRootProps()} class="bg-base-200 rounded-lg shadow-sm flex flex-col items-center p-6 mb-6">
<input {...dropzone.getInputProps()} /> <input {...dropzone.getInputProps()} />
<UploadIcon size="42" /> <UploadIcon size="42" />
<p class="mt-4 has-text-centered"> <p class="mt-4 text-center">
{dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"} {dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"}
</p> </p>
</div> </div>

View file

@ -1,5 +1,5 @@
import { createDropzone } from "@soorria/solid-dropzone"; import { createDropzone } from "@soorria/solid-dropzone";
import { UploadIcon } from "lucide-solid"; import { Info, UploadIcon } from "lucide-solid";
import HugeUploader from "huge-uploader"; import HugeUploader from "huge-uploader";
import { SERVER_URL } from "../constants"; import { SERVER_URL } from "../constants";
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
@ -53,16 +53,20 @@ export default function Upload(props: { podcastId: string }) {
return <> return <>
<Show when={statusText()}> <Show when={statusText()}>
<div class="notification is-info"> <div role="alert" class="alert">
{uploadProgress() && <progress class="progress mb-4" value={uploadProgress()!} max="100">{uploadProgress()}%</progress>} <Info class="stroke-primary h-6 w-6 shrink-0" />
<div class="w-full">
{statusText()} {statusText()}
{uploadProgress() && <progress class="progress progress-primary w-full" value={uploadProgress()!} max="100">{uploadProgress()}%</progress>}
{uploading() && <p class="text-xs">Do not navigate away until the upload has completed as any progress will be lost.</p>}
</div>
</div> </div>
</Show> </Show>
<Show when={!uploading()}> <Show when={!uploading()}>
<div {...dropzone.getRootProps()} class="box is-flex is-flex-direction-column is-align-items-center p-6 mb-6"> <div {...dropzone.getRootProps()} class="bg-base-200 rounded-lg shadow-sm flex flex-col items-center p-6 mb-6">
<input {...dropzone.getInputProps()} /> <input {...dropzone.getInputProps()} />
<UploadIcon size="42" /> <UploadIcon size="42" />
<p class="mt-4 has-text-centered"> <p class="mt-4 text-center">
{dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"} {dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"}
</p> </p>
</div> </div>

View file

@ -1,4 +1,6 @@
export function humanFileSize(bytes: number, si=false, dp=1) { const pluralRules = new Intl.PluralRules("en-GB");
export function humanFileSize(bytes: number, si = false, dp = 1) {
const thresh = si ? 1000 : 1024; const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) { if (Math.abs(bytes) < thresh) {
@ -9,7 +11,7 @@ export function humanFileSize(bytes: number, si=false, dp=1) {
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1; let u = -1;
const r = 10**dp; const r = 10 ** dp;
do { do {
bytes /= thresh; bytes /= thresh;
@ -19,3 +21,14 @@ export function humanFileSize(bytes: number, si=false, dp=1) {
return bytes.toFixed(dp) + ' ' + units[u]; return bytes.toFixed(dp) + ' ' + units[u];
} }
export function pluralize(count: number, singular: string, plural: string): string {
const grammaticalNumber = pluralRules.select(count);
switch (grammaticalNumber) {
case "one":
return `${count} ${singular}`;
default:
return `${count} ${plural}`;
}
}

View file

@ -1,68 +1,11 @@
:root { @import "tailwindcss";
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; @plugin "@tailwindcss/typography";
line-height: 1.5; @plugin "daisyui";
font-weight: 400;
color-scheme: light dark; html, body, #root {
color: rgba(255, 255, 255, 0.87); height: 100%;
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
a { .container {
font-weight: 500; @apply py-8 px-4;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} }

View file

@ -5,12 +5,13 @@ import { lazy } from 'solid-js';
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
import { client as apiClient } from './client/sdk.gen'; import { client as apiClient } from './client/sdk.gen';
import "bulma/css/bulma.min.css";
import { SERVER_URL } from './constants'; import { SERVER_URL } from './constants';
import Protected from './components/Protected'; import Protected from './components/Protected';
import AuthProvider from './components/AuthProvider'; import AuthProvider from './components/AuthProvider';
import { refreshToken, user } from './auth'; import { refreshToken, user } from './auth';
import "./index.css";
const wrapper = document.getElementById('root'); const wrapper = document.getElementById('root');
if (!wrapper) { if (!wrapper) {

View file

@ -1,6 +1,7 @@
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { onMount } from "solid-js"; import { onMount } from "solid-js";
import { fetchUser, getUserManager } from "../auth"; import { fetchUser, getUserManager } from "../auth";
import Loading from "../components/loading";
const Callback = () => { const Callback = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -11,7 +12,7 @@ const Callback = () => {
navigate("/admin"); navigate("/admin");
}); });
return <p>Signing in...</p>; return <Loading loadingText="Signing in..." />;
} }
export default Callback; export default Callback;

View file

@ -2,13 +2,11 @@ import { login } from "../auth"
const Login = () => { const Login = () => {
return ( return (
<main> <main class="flex items-center justify-center h-full">
<section class="section"> <div class="w-100 max-w-100 bg-base-100 shadow-sm p-8 rounded-md">
<div class="container"> <h1 class="text-2xl mb-2">Login</h1>
<h1 class="title">Login</h1> <button class="btn btn-primary w-full" onClick={login}>Sign in with OIDC</button>
<button class="button is-primary" onClick={login}>Sign in with OIDC</button>
</div> </div>
</section>
</main> </main>
); );
} }

View file

@ -5,7 +5,7 @@ import { A, useNavigate, useParams } from "@solidjs/router";
import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen"; import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen";
import Error from "../../components/error"; import Error from "../../components/error";
import Loading from "../../components/loading"; import Loading from "../../components/loading";
import { Save, Trash } from "lucide-solid"; import { AlertCircle, CircleCheck, Save, Trash } from "lucide-solid";
import { SERVER_URL } from "../../constants"; import { SERVER_URL } from "../../constants";
import { PodcastEpisodePublic } from "../../client"; import { PodcastEpisodePublic } from "../../client";
import { humanFileSize } from "../../helpers"; import { humanFileSize } from "../../helpers";
@ -94,78 +94,62 @@ export default function AdminEpisode() {
<AdminLayout> <AdminLayout>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <h1 class="text-4xl line-clamp-2 overflow-ellipsis mb-2">{episodeQuery.data?.name}</h1>
<h1 class="title">{episodeQuery.data?.name}</h1> <p class="subtitle text-base-content/50 text-xl">{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()}&ensp;&bull;&ensp;{episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}</p>
<h2 class="subtitle">{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()}&ensp;&bull;&ensp;{episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}</h2> {episodeQuery.data?.description_html && <div class="mt-4 prose prose-neutral" innerHTML={episodeQuery.data?.description_html} />}
{episodeQuery.data?.description_html && <div class="content" innerHTML={episodeQuery.data?.description_html} />}
</div> </div>
</section> <div class="container mx-auto">
<section class="section"> <h2 class="text-2xl">Update Episode</h2>
<div class="container">
<h2 class="title is-4">Update Episode</h2>
{updateEpisode.isSuccess && <div class="notification is-success"> {updateEpisode.isSuccess && <div role="alert" class="alert alert-success">
<button class="delete" onClick={() => updateEpisode.reset()}></button> <CircleCheck class="stroke-current h-6 w-6 shrink-0" />
This episode has been updated successfully. <p>This episode has been updated successfully.</p>
<button class="btn btn-sm" onClick={() => updateEpisode.reset()}>Ok</button>
</div>} </div>}
{updateEpisode.isError && <div class="notification is-danger"> {updateEpisode.isError && <div role="alert" class="alert alert-error">
<button class="delete" onClick={() => updateEpisode.reset()}></button> <AlertCircle class="stroke-current h-6 w-6 shrink-0" />
Something went wrong. {JSON.stringify(updateEpisode.error)} <p>Something went wrong. {JSON.stringify(updateEpisode.error)}</p>
<button class="btn btn-sm" onClick={() => updateEpisode.reset()}>Ok</button>
</div>} </div>}
<div class="field"> <fieldset class="fieldset">
<label class="label">Name</label> <legend class="fieldset-legend">Name</legend>
<div class="control"> <input class="input w-full" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
<input class="input" type="text" value={name()} onInput={(e) => setName(e.target.value)} /> </fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Description</legend>
<textarea class="textarea h-24 w-full" onChange={(e) => setDescription(e.target.value)}>{description()}</textarea>
<p class="label">The description supports <a href="https://www.markdownguide.org/cheat-sheet/" class="underline hover:no-underline">Markdown</a>. Leave this empty to unset the description.</p>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Publish Date</legend>
<input class="input w-full" type="datetime-local" value={publishDate()} onInput={(e) => setPublishDate(e.target.value)} />
</fieldset>
<div class="flex flex-row justify-end mt-2">
<button class="btn btn-primary" onClick={() => saveChanges()}><Save /> Save Changes</button>
</div> </div>
<div class="field"> <h2 class="text-2xl mt-8">Additional Upload</h2>
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" onChange={(e) => setDescription(e.target.value)}>{description()}</textarea>
</div>
<p class="help">The description supports <a href="https://www.markdownguide.org/cheat-sheet/">Markdown</a>. Leave this empty to unset the description.</p>
</div>
<div class="field">
<label class="label">Publish Date</label>
<div class="control">
<input class="input" type="datetime-local" value={publishDate()} onInput={(e) => setPublishDate(e.target.value)} />
</div>
</div>
<button class="button" onClick={() => saveChanges()}><Save /> Save Changes</button>
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title is-4">Additional Upload</h2>
<p class="mb-4">Drag extra files here</p> <p class="mb-4">Drag extra files here</p>
<UploadEpisodeAdditional podcastId={params.podcastId} episodeId={params.episodeId} /> <UploadEpisodeAdditional podcastId={params.podcastId} episodeId={params.episodeId} />
</div>
</section>
<section class="section"> <h2 class="text-2xl mb-4 mt-8">Info</h2>
<div class="container">
<h2 class="title is-4">Info</h2>
<ul> <ul>
<li><b>Audio URL:</b> <A href={audioUrl()}>{audioUrl()}</A></li> <li><b>Audio URL:</b> <A href={audioUrl()} class="text-primary underline hover:no-underline">{audioUrl()}</A></li>
<li><b>SHA256 Checksum:</b> <code>{episodeQuery.data?.file_hash}</code></li> <li><b>SHA256 Checksum:</b> <code class="overflow-x-scroll">{episodeQuery.data?.file_hash}</code></li>
<li><b>File Size:</b> {episodeQuery.data?.file_size ? humanFileSize(episodeQuery.data?.file_size, true) : "?"}</li> <li><b>File Size:</b> {episodeQuery.data?.file_size ? humanFileSize(episodeQuery.data?.file_size, true) : "?"}</li>
</ul> </ul>
</div> </div>
</section>
<section class="section"> <div class="container mx-auto py-4! rounded-lg border-1 border-error text-error">
<div class="container"> <h2 class="text-2xl mb-4">Danger Zone</h2>
<h2 class="title is-4">Danger Zone</h2> <button class="btn btn-error" onClick={() => deleteAction()}><Trash /> Delete</button>
<button class="button is-danger" onClick={() => deleteAction()}><Trash /> Delete</button>
</div> </div>
</section>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</AdminLayout> </AdminLayout>

View file

@ -5,7 +5,7 @@ import { A, useNavigate, useParams } from "@solidjs/router";
import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen"; import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen";
import Error from "../../components/error"; import Error from "../../components/error";
import Loading from "../../components/loading"; import Loading from "../../components/loading";
import { List, Pencil, RefreshCw, Save, Settings, Trash } from "lucide-solid"; import { AlertCircle, CheckCircle, Info, List, Pencil, RefreshCw, Save, Settings, Trash } from "lucide-solid";
import { SERVER_URL } from "../../constants"; import { SERVER_URL } from "../../constants";
import Upload from "../../components/upload"; import Upload from "../../components/upload";
import UploadImage from "../../components/upload-image"; import UploadImage from "../../components/upload-image";
@ -83,50 +83,41 @@ export default function AdminPodcast() {
<AdminLayout> <AdminLayout>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <div class="flex flex-row gap-4">
<div class="columns">
<Show when={podcastQuery.data?.image_filename}> <Show when={podcastQuery.data?.image_filename}>
<div class="column"> <img class="size-48 md:size-72 rounded-lg" src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
<figure class="image is-128x128"> </Show>
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} /> <div class="grow">
</figure> <h1 class="text-4xl mb-2">{podcastQuery.data?.name}</h1>
<p>{podcastQuery.data?.description}</p>
<Show when={episodeQuery.data?.length || 0 > 0}>
<div role="alert" class="alert mt-4">
<Info class="h-6 w-6 shrink-0 stroke-info" />
<span>Subscribe at <code>{new URL(`${params.podcastId}.xml`, SERVER_URL).href}</code></span>
</div> </div>
</Show> </Show>
<div class="column is-full">
<h1 class="title">{podcastQuery.data?.name}</h1>
<p>{podcastQuery.data?.description}</p>
</div> </div>
</div> </div>
<Show when={episodeQuery.data?.length || 0 > 0}> <div role="tablist" class="tabs tabs-box justify-center mt-4 w-full">
<p><strong>Subscribe at:</strong></p> <button role="tab" class="tab" classList={{ "tab-active": tab() === 0 }} onClick={() => setTab(0)}><List class="size-5 me-2" /> Episodes</button>
<pre><code>{new URL(`${params.podcastId}.xml`, SERVER_URL).href}</code></pre> <button role="tab" class="tab" classList={{ "tab-active": tab() === 1 }} onClick={() => setTab(1)}><Settings class="size-5 me-2" /> Settings</button>
</Show>
</div>
</section>
<section class="section py-0">
<div class="container">
<div class="tabs is-centered">
<ul>
<li classList={{ "is-active": tab() === 0 }}><a role="button" onClick={() => setTab(0)}><List class="icon is-small" /> Episodes</a></li>
<li classList={{ "is-active": tab() === 1 }}><a role="button" onClick={() => setTab(1)}><Settings class="icon is-small" /> Settings</a></li>
</ul>
</div> </div>
</div> </div>
</section>
<Switch> <Switch>
<Match when={tab() === 0}> <Match when={tab() === 0}>
<section class="section"> <div class="container mx-auto">
<div class="container">
<Upload podcastId={params.podcastId} /> <Upload podcastId={params.podcastId} />
<h2 class="title is-4">Published Episodes</h2> <div class="flex flex-row mb-4">
<div class="buttons"> <h2 class="text-2xl grow">Published Episodes</h2>
<button class="button is-primary" onClick={() => episodeQuery.refetch()}><RefreshCw /> Refresh</button> <button class="btn btn-sm" onClick={() => episodeQuery.refetch()}><RefreshCw class="size-4" /> Refresh</button>
</div> </div>
<div class="table-container">
<table class="table is-narrow is-fullwidth"> <div class="overflow-x-auto">
<table class="table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -143,7 +134,7 @@ export default function AdminPodcast() {
<td>{episode.publish_date ? (new Date(episode.publish_date)).toLocaleString() : "?"}</td> <td>{episode.publish_date ? (new Date(episode.publish_date)).toLocaleString() : "?"}</td>
<td>{episode.duration ? `${(episode.duration / 60).toFixed(0)}min` : "?"}</td> <td>{episode.duration ? `${(episode.duration / 60).toFixed(0)}min` : "?"}</td>
<td> <td>
<A href={`/admin/${params.podcastId}/${episode.id}`}><Pencil class="icon is-small" /> Edit</A> <A href={`/admin/${params.podcastId}/${episode.id}`} class="btn btn-ghost btn-xs"><Pencil class="size-4" /> Edit</A>
</td> </td>
</tr> </tr>
)} )}
@ -152,53 +143,48 @@ export default function AdminPodcast() {
</table> </table>
</div> </div>
</div> </div>
</section>
</Match> </Match>
<Match when={tab() === 1}> <Match when={tab() === 1}>
<section class="section"> <div class="container mx-auto">
<div class="container"> {updatePodcast.isSuccess && <div role="alert" class="alert alert-success alert-vertical sm:alert-horizontal">
{updatePodcast.isSuccess && <div class="notification is-success"> <CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
<button class="delete" onClick={() => updatePodcast.reset()}></button> <span>The podcast has been updated successfully.</span>
This episode has been updated successfully. <div>
<button class="btn btn-sm" onClick={() => updatePodcast.reset()}>Ok</button>
</div>
</div>} </div>}
{updatePodcast.isError && <div class="notification is-danger"> {updatePodcast.isError && <div role="alert" class="alert alert-error alert-vertical sm:alert-horizontal">
<button class="delete" onClick={() => updatePodcast.reset()}></button> <AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
Something went wrong. {JSON.stringify(updatePodcast.error)} <span>Something went wrong. {JSON.stringify(updatePodcast.error)}</span>
<div>
<button class="btn btn-sm" onClick={() => updatePodcast.reset()}>Ok</button>
</div>
</div>} </div>}
<div class="field"> <fieldset class="fieldset">
<label class="label">Name</label> <legend class="fieldset-legend">Name</legend>
<div class="control"> <input class="input w-full" type="text" value={name()} onInput={(e) => setName(e.target.value)} />
<input class="input" type="text" value={name()} onInput={(e) => setName(e.target.value)} /> </fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Description</legend>
<textarea class="textarea h-24 w-full" onInput={(e) => setDescription(e.target.value)}>{description()}</textarea>
</fieldset>
<div class="flex flex-row justify-end mt-2">
<button class="btn btn-primary" onClick={() => saveChanges()}><Save /> Save Changes</button>
</div> </div>
<div class="field"> <h2 class="text-2xl mb-4 mt-8">Podcast Image</h2>
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" onInput={(e) => setDescription(e.target.value)}>{description()}</textarea>
</div>
</div>
<button class="button" onClick={() => saveChanges()}><Save /> Save Changes</button>
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title is-4">Podcast Image</h2>
<UploadImage podcastId={params.podcastId} /> <UploadImage podcastId={params.podcastId} />
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title is-4">Danger Zone</h2>
<button class="button is-danger" onClick={() => deleteAction()}><Trash /> Delete</button>
</div> </div>
</section> <div class="container mx-auto py-4! rounded-lg border-1 border-error text-error">
<h2 class="text-2xl mb-4">Danger Zone</h2>
<button class="btn btn-error" onClick={() => deleteAction()}><Trash /> Delete</button>
</div>
</Match> </Match>
</Switch> </Switch>
</Suspense> </Suspense>

View file

@ -8,6 +8,7 @@ import Error from "../../components/error";
import { SERVER_URL } from "../../constants"; import { SERVER_URL } from "../../constants";
import { Eye, Plus } from "lucide-solid"; import { Eye, Plus } from "lucide-solid";
import { PodcastPublic } from "../../client"; import { PodcastPublic } from "../../client";
import { pluralize } from "../../helpers";
export default function AdminPodcasts() { export default function AdminPodcasts() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -40,39 +41,37 @@ export default function AdminPodcasts() {
<AdminLayout> <AdminLayout>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="section"> <div class="container mx-auto">
<div class="container"> <div class="flex flex-row mb-4">
<h1 class="title">Podcasts</h1> <h1 class="text-2xl grow">Podcasts</h1>
<div class="mb-4"> <button class="btn btn-sm" onClick={() => createAction()}>
<button class="button" onClick={() => createAction()}>
<Plus /> <Plus />
Create New
</button> </button>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
<For each={query.data}> <For each={query.data}>
{(podcast) => ( {(podcast) => (
<div class="card"> <div class="card bg-base-100 shadow-sm">
<div class="card-content">
<div class="columns">
<Show when={podcast.image_filename}> <Show when={podcast.image_filename}>
<div class="column"> <figure>
<figure class="image is-64x64">
<img src={new URL("/files/" + podcast.id + "/" + podcast.image_filename, SERVER_URL).href} /> <img src={new URL("/files/" + podcast.id + "/" + podcast.image_filename, SERVER_URL).href} />
</figure> </figure>
</div>
</Show> </Show>
<div class="column is-full"> <div class="card-body">
<h5 class="title is-4">{podcast.name}</h5> <h2 class="card-title mb-0">{podcast.name}</h2>
<p class="mb-4">{podcast.description}</p> <p class="text-base-content/50">{pluralize(podcast.episode_count, "episode", "episodes")}</p>
<A href={`/admin/${podcast.id}`} class="button"><Eye /> View</A> <p>{podcast.description}</p>
</div> <div class="card-actions justify-end mt-2">
<A class="btn btn-primary" href={`/admin/${podcast.id}`}><Eye /> View</A>
</div> </div>
</div> </div>
</div> </div>
)} )}
</For> </For>
</div> </div>
</section> </div>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</AdminLayout> </AdminLayout>

View file

@ -1,11 +1,15 @@
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery } from "@tanstack/solid-query";
import { readPodcastOptions, readEpisodesOptions } from "../client/@tanstack/solid-query.gen"; import { readPodcastOptions, readEpisodesOptions } from "../client/@tanstack/solid-query.gen";
import { ErrorBoundary, For, Show, Suspense } from "solid-js"; import { createEffect, ErrorBoundary, For, Show, Suspense } from "solid-js";
import Error from "../components/error"; import Error from "../components/error";
import Loading from "../components/loading"; import Loading from "../components/loading";
import { DownloadIcon, MusicIcon } from "lucide-solid"; import { DownloadIcon, MusicIcon } from "lucide-solid";
import { SERVER_URL } from "../constants"; import { SERVER_URL } from "../constants";
import { Vibrant, WorkerPipeline } from "node-vibrant/worker";
import PipelineWorker from "node-vibrant/worker.worker?worker";
Vibrant.use(new WorkerPipeline(PipelineWorker as never));
export default function Podcast() { export default function Podcast() {
const params = useParams(); const params = useParams();
@ -23,61 +27,73 @@ export default function Podcast() {
podcast_id: params.podcastId, podcast_id: params.podcastId,
} }
}) })
})) }));
createEffect(async () => {
if (!podcastQuery.data?.image_filename) {
return;
}
const imageUrl = new URL(`/files/${params.podcastId}/${podcastQuery.data.image_filename}`, SERVER_URL).href;
const palette = await Vibrant.from(imageUrl).getPalette();
console.log(palette);
const swatch = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? palette.DarkVibrant : palette.Vibrant;
if (swatch) {
document.documentElement.style.setProperty("--color-primary", swatch.hex);
document.documentElement.style.setProperty("--color-primary-content", swatch.bodyTextColor)
}
});
return ( return (
<main> <main>
<ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}> <ErrorBoundary fallback={(err, reset) => <Error message={err} reset={reset} />}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<section class="hero is-medium is-primary"> <div class="bg-primary transition-colors">
<div class="hero-body"> <div class="container mx-auto py-12!">
<div class="columns"> <div class="flex flex-row gap-8">
<Show when={podcastQuery.data?.image_filename}> <Show when={podcastQuery.data?.image_filename}>
<div class="column"> <img class="size-64 md:size-72 rounded-2xl" src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
<figure class="image is-128x128">
<img src={new URL(`/files/${params.podcastId}/${podcastQuery.data!.image_filename}`, SERVER_URL).href} />
</figure>
</div>
</Show> </Show>
<div class="column is-full"> <div>
<h1 class="title">{podcastQuery.data?.name}</h1> <h1 class="text-4xl text-primary-content font-semibold transition-colors">{podcastQuery.data?.name}</h1>
<p class="subtitle">{podcastQuery.data?.description}</p> <p class="text-lg text-primary-content mt-2 transition-colors">{podcastQuery.data?.description}</p>
</div> </div>
</div> </div>
</div> </div>
</section> </div>
<section class="section">
<div class="container is-max-desktop"> <div class="container mx-auto">
<For each={episodeQuery.data}> <For each={episodeQuery.data}>
{(episode) => ( {(episode) => (
<article class="media"> <div class="bg-base-200 flex flex-row p-4 gap-4 mb-16 rounded-lg">
<figure class="media-left"> <div>
<p class="image is-64x64 has-background-primary is-flex is-align-content-center"> <div class="bg-primary p-4 rounded-lg transition-colors">
<MusicIcon class="p-4 has-text-dark" /> <MusicIcon class="stroke-primary-content size-8 transition-colors" />
</p> </div>
</figure> </div>
<div class="media-content">
<div class="content"> <div class="grow">
<p class="title is-5">{episode.name}</p> <div class="flex flex-row mb-4">
<Show when={episode.publish_date}> <div class="grow">
<p class="subtitle is-6">{new Date(episode.publish_date!).toLocaleDateString()}</p> <h4 class="text-xl line-clamp-2 overflow-ellipsis mb-1">{episode.name}</h4>
</Show> <p class="text-base-content/50">{new Date(episode.publish_date!).toLocaleDateString()}</p>
</div>
<a class="bg-primary mb-auto rounded-full p-3 text-primary-content hover:text-primary hover:bg-primary/20 transition-colors" href={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}><DownloadIcon /></a>
</div>
<figure class="m-0 mb-4"> <figure class="m-0 mb-4">
<audio controls src={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}></audio> <audio controls src={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()} class="w-full"></audio>
</figure> </figure>
<Show when={episode.description_html}> <Show when={episode.description_html}>
<p innerHTML={episode.description_html!}></p> <p innerHTML={episode.description_html!}></p>
</Show> </Show>
</div> </div>
</div> </div>
<div class="media-right">
<a href={new URL(`/files/${episode.podcast_id}/${episode.id}.m4a`, SERVER_URL).toString()}><DownloadIcon /></a>
</div>
</article>
)} )}
</For> </For>
</div> </div>
</section>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</main> </main>

View file

@ -1,8 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid' import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [solid()], plugins: [tailwindcss(), solid()],
build: { build: {
assetsDir: "static", assetsDir: "static",
} }

View file

@ -487,7 +487,10 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
elif ( elif (
filename.endswith(".jpg") or filename.endswith(".png") filename.endswith(".jpg") or filename.endswith(".png")
) and filename == podcast.image_filename: ) and filename == podcast.image_filename:
return FileResponse(settings.directory / podcast.image_filename) return FileResponse(
settings.directory / podcast.image_filename,
headers={"Access-Control-Allow-Origin": "*"},
)
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")

View file

@ -24,10 +24,16 @@ class Podcast(PodcastBase, table=True):
) )
image_filename: Optional[str] = Field(default=None) image_filename: Optional[str] = Field(default=None)
@computed_field
@property
def episode_count(self) -> int:
return len(self.episodes)
class PodcastPublic(PodcastBase): class PodcastPublic(PodcastBase):
id: str id: str
image_filename: Optional[str] = Field(default=None) image_filename: Optional[str] = Field(default=None)
episode_count: int
class PodcastCreate(PodcastBase): class PodcastCreate(PodcastBase):