feat(channel): add logo support with position and opacity settings
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ChannelPasswordModal,
|
||||
} from "./components";
|
||||
import type { SubtitleTrack } from "./components/video-player";
|
||||
import type { LogoPosition } from "@/lib/types";
|
||||
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import { useAuthContext } from "@/context/auth-context";
|
||||
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
||||
@@ -33,6 +34,15 @@ import {
|
||||
const IDLE_TIMEOUT_MS = 3500;
|
||||
const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
||||
|
||||
function logoPositionClass(pos?: LogoPosition) {
|
||||
switch (pos) {
|
||||
case "top_left": return "top-0 left-0";
|
||||
case "bottom_left": return "bottom-0 left-0";
|
||||
case "bottom_right":return "bottom-0 right-0";
|
||||
default: return "top-0 right-0";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -499,6 +509,21 @@ function TvPageContent() {
|
||||
{/* ── Base layer ─────────────────────────────────────────────── */}
|
||||
<div className="absolute inset-0">{renderBase()}</div>
|
||||
|
||||
{/* ── Logo watermark — always visible, not tied to idle state ── */}
|
||||
{channel?.logo && (
|
||||
<div
|
||||
className={`pointer-events-none absolute z-10 p-3 ${logoPositionClass(channel.logo_position)}`}
|
||||
style={{ opacity: channel.logo_opacity ?? 1 }}
|
||||
>
|
||||
{channel.logo.trimStart().startsWith("<") ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: channel.logo }} className="h-12 w-auto" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={channel.logo} alt="" className="h-12 w-auto object-contain" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Channel password modal ──────────────────────────────────── */}
|
||||
{showChannelPasswordModal && (
|
||||
<ChannelPasswordModal
|
||||
|
||||
Reference in New Issue
Block a user