feat(channel): add logo support with position and opacity settings

This commit is contained in:
2026-03-14 02:27:16 +01:00
parent e610c23fea
commit da714840ee
11 changed files with 204 additions and 6 deletions

View File

@@ -13,6 +13,7 @@ import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
import type {
AccessMode,
ChannelResponse,
LogoPosition,
ProgrammingBlock,
BlockContent,
FillStrategy,
@@ -711,6 +712,9 @@ interface EditChannelSheetProps {
auto_schedule: boolean;
access_mode?: AccessMode;
access_password?: string;
logo?: string | null;
logo_position?: LogoPosition;
logo_opacity?: number;
},
) => void;
isPending: boolean;
@@ -737,8 +741,12 @@ export function EditChannelSheet({
const [autoSchedule, setAutoSchedule] = useState(false);
const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const [logo, setLogo] = useState<string | null>(null);
const [logoPosition, setLogoPosition] = useState<LogoPosition>("top_right");
const [logoOpacity, setLogoOpacity] = useState(100);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (channel) {
@@ -750,6 +758,9 @@ export function EditChannelSheet({
setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public");
setAccessPassword("");
setLogo(channel.logo ?? null);
setLogoPosition(channel.logo_position ?? "top_right");
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
setSelectedBlockId(null);
setFieldErrors({});
}
@@ -779,6 +790,9 @@ export function EditChannelSheet({
auto_schedule: autoSchedule,
access_mode: accessMode !== "public" ? accessMode : "public",
access_password: accessPassword || "",
logo: logo,
logo_position: logoPosition,
logo_opacity: logoOpacity / 100,
});
};
@@ -883,6 +897,92 @@ export function EditChannelSheet({
)}
</section>
{/* Logo */}
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Logo</h3>
<div className="space-y-3 rounded-md border border-zinc-700/50 bg-zinc-800/50 p-3">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="xs"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
onClick={() => fileInputRef.current?.click()}
>
Upload image
</Button>
{logo && (
<Button
type="button"
variant="ghost"
size="xs"
className="text-zinc-500 hover:text-red-400"
onClick={() => setLogo(null)}
>
Clear
</Button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*,.svg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setLogo(ev.target?.result as string ?? null);
reader.readAsDataURL(file);
e.target.value = "";
}}
/>
</div>
<Field label="URL or SVG markup" hint="Paste a remote URL, data URI, or inline SVG">
<textarea
rows={3}
value={logo ?? ""}
onChange={(e) => setLogo(e.target.value || null)}
placeholder="https://… or <svg>…</svg>"
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>
{logo && (
<div className="flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 p-3">
{logo.trimStart().startsWith("<") ? (
<div dangerouslySetInnerHTML={{ __html: logo }} className="h-12 w-auto" />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt="" className="h-12 w-auto object-contain" />
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Position">
<NativeSelect value={logoPosition} onChange={(v) => setLogoPosition(v as LogoPosition)}>
<option value="top_left">Top left</option>
<option value="top_right">Top right</option>
<option value="bottom_left">Bottom left</option>
<option value="bottom_right">Bottom right</option>
</NativeSelect>
</Field>
<Field label={`Opacity (${logoOpacity}%)`}>
<input
type="range"
min={0}
max={100}
value={logoOpacity}
onChange={(e) => setLogoOpacity(Number(e.target.value))}
className="w-full accent-zinc-400 mt-2"
/>
</Field>
</div>
</div>
</section>
{/* Programming blocks */}
<section className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -148,6 +148,9 @@ export default function DashboardPage() {
auto_schedule: boolean;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
logo?: string | null;
logo_position?: import("@/lib/types").LogoPosition;
logo_opacity?: number;
},
) => {
updateChannel.mutate(