diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index 30008cf..e0a5b9a 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -76,6 +76,10 @@ pub struct UpdateChannelRequest { pub access_mode: Option, /// Empty string clears the password; non-empty re-hashes. pub access_password: Option, + /// `Some(None)` = clear logo, `Some(Some(url))` = set logo, `None` = unchanged. + pub logo: Option>, + pub logo_position: Option, + pub logo_opacity: Option, } #[derive(Debug, Serialize)] @@ -89,6 +93,9 @@ pub struct ChannelResponse { pub recycle_policy: domain::RecyclePolicy, pub auto_schedule: bool, pub access_mode: domain::AccessMode, + pub logo: Option, + pub logo_position: domain::LogoPosition, + pub logo_opacity: f32, pub created_at: DateTime, pub updated_at: DateTime, } @@ -105,6 +112,9 @@ impl From for ChannelResponse { recycle_policy: c.recycle_policy, auto_schedule: c.auto_schedule, access_mode: c.access_mode, + logo: c.logo, + logo_position: c.logo_position, + logo_opacity: c.logo_opacity, created_at: c.created_at, updated_at: c.updated_at, } diff --git a/k-tv-backend/api/src/routes/channels/crud.rs b/k-tv-backend/api/src/routes/channels/crud.rs index d5546bb..ab9f545 100644 --- a/k-tv-backend/api/src/routes/channels/crud.rs +++ b/k-tv-backend/api/src/routes/channels/crud.rs @@ -101,6 +101,15 @@ pub(super) async fn update_channel( channel.access_password_hash = Some(infra::auth::hash_password(&pw)); } } + if let Some(logo) = payload.logo { + channel.logo = logo; + } + if let Some(pos) = payload.logo_position { + channel.logo_position = pos; + } + if let Some(opacity) = payload.logo_opacity { + channel.logo_opacity = opacity.clamp(0.0, 1.0); + } channel.updated_at = Utc::now(); let channel = state.channel_service.update(channel).await?; diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index 48828a7..70922aa 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::value_objects::{ - AccessMode, BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, - RecyclePolicy, SlotId, + AccessMode, BlockId, ChannelId, ContentType, FillStrategy, LogoPosition, MediaFilter, + MediaItemId, RecyclePolicy, SlotId, }; /// A user in the system. @@ -85,6 +85,9 @@ pub struct Channel { pub auto_schedule: bool, pub access_mode: AccessMode, pub access_password_hash: Option, + pub logo: Option, + pub logo_position: LogoPosition, + pub logo_opacity: f32, pub created_at: DateTime, pub updated_at: DateTime, } @@ -107,6 +110,9 @@ impl Channel { auto_schedule: false, access_mode: AccessMode::default(), access_password_hash: None, + logo: None, + logo_position: LogoPosition::default(), + logo_opacity: 1.0, created_at: now, updated_at: now, } diff --git a/k-tv-backend/domain/src/value_objects/scheduling.rs b/k-tv-backend/domain/src/value_objects/scheduling.rs index a75bc95..20f68d1 100644 --- a/k-tv-backend/domain/src/value_objects/scheduling.rs +++ b/k-tv-backend/domain/src/value_objects/scheduling.rs @@ -1,6 +1,17 @@ use serde::{Deserialize, Serialize}; use std::fmt; +/// Position of the channel logo watermark overlay. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum LogoPosition { + TopLeft, + #[default] + TopRight, + BottomLeft, + BottomRight, +} + /// Controls who can view a channel's broadcast and stream. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/k-tv-backend/infra/src/channel_repository/mapping.rs b/k-tv-backend/infra/src/channel_repository/mapping.rs index 9847d56..c9d02d6 100644 --- a/k-tv-backend/infra/src/channel_repository/mapping.rs +++ b/k-tv-backend/infra/src/channel_repository/mapping.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use sqlx::FromRow; use uuid::Uuid; -use domain::{AccessMode, Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId}; +use domain::{AccessMode, Channel, ChannelId, DomainError, LogoPosition, RecyclePolicy, ScheduleConfig, UserId}; #[derive(Debug, FromRow)] pub(super) struct ChannelRow { @@ -16,6 +16,9 @@ pub(super) struct ChannelRow { pub auto_schedule: i64, pub access_mode: String, pub access_password_hash: Option, + pub logo: Option, + pub logo_position: String, + pub logo_opacity: f32, pub created_at: String, pub updated_at: String, } @@ -51,6 +54,11 @@ impl TryFrom for Channel { ) .unwrap_or_default(); + let logo_position: LogoPosition = serde_json::from_value( + serde_json::Value::String(row.logo_position), + ) + .unwrap_or_default(); + Ok(Channel { id, owner_id, @@ -62,6 +70,9 @@ impl TryFrom for Channel { auto_schedule: row.auto_schedule != 0, access_mode, access_password_hash: row.access_password_hash, + logo: row.logo, + logo_position, + logo_opacity: row.logo_opacity, created_at: parse_dt(&row.created_at)?, updated_at: parse_dt(&row.updated_at)?, }) @@ -69,4 +80,4 @@ impl TryFrom for Channel { } pub(super) const SELECT_COLS: &str = - "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at"; + "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, created_at, updated_at"; diff --git a/k-tv-backend/infra/src/channel_repository/sqlite.rs b/k-tv-backend/infra/src/channel_repository/sqlite.rs index 3f09eb6..d30cd3a 100644 --- a/k-tv-backend/infra/src/channel_repository/sqlite.rs +++ b/k-tv-backend/infra/src/channel_repository/sqlite.rs @@ -63,11 +63,16 @@ impl ChannelRepository for SqliteChannelRepository { .and_then(|v| v.as_str().map(str::to_owned)) .unwrap_or_else(|| "public".to_owned()); + let logo_position = serde_json::to_value(&channel.logo_position) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "top_right".to_owned()); + sqlx::query( r#" INSERT INTO channels - (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, logo, logo_position, logo_opacity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, @@ -77,6 +82,9 @@ impl ChannelRepository for SqliteChannelRepository { auto_schedule = excluded.auto_schedule, access_mode = excluded.access_mode, access_password_hash = excluded.access_password_hash, + logo = excluded.logo, + logo_position = excluded.logo_position, + logo_opacity = excluded.logo_opacity, updated_at = excluded.updated_at "#, ) @@ -90,6 +98,9 @@ impl ChannelRepository for SqliteChannelRepository { .bind(channel.auto_schedule as i64) .bind(&access_mode) .bind(&channel.access_password_hash) + .bind(&channel.logo) + .bind(&logo_position) + .bind(channel.logo_opacity) .bind(channel.created_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339()) .execute(&self.pool) diff --git a/k-tv-backend/migrations_sqlite/20240105000000_add_logo_to_channels.sql b/k-tv-backend/migrations_sqlite/20240105000000_add_logo_to_channels.sql new file mode 100644 index 0000000..ad39f37 --- /dev/null +++ b/k-tv-backend/migrations_sqlite/20240105000000_add_logo_to_channels.sql @@ -0,0 +1,3 @@ +ALTER TABLE channels ADD COLUMN logo TEXT; +ALTER TABLE channels ADD COLUMN logo_position TEXT NOT NULL DEFAULT 'top_right'; +ALTER TABLE channels ADD COLUMN logo_opacity REAL NOT NULL DEFAULT 1.0; diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index c099de0..3bfa634 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -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("public"); const [accessPassword, setAccessPassword] = useState(""); + const [logo, setLogo] = useState(null); + const [logoPosition, setLogoPosition] = useState("top_right"); + const [logoOpacity, setLogoOpacity] = useState(100); const [selectedBlockId, setSelectedBlockId] = useState(null); const [fieldErrors, setFieldErrors] = useState({}); + const fileInputRef = useRef(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({ )} + {/* Logo */} +
+

Logo

+ +
+
+ + {logo && ( + + )} + { + 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 = ""; + }} + /> +
+ + +