feat: initialize k-tv-frontend with Next.js and Tailwind CSS

- Added package.json with dependencies and scripts for development, build, and linting.
- Created postcss.config.mjs for Tailwind CSS integration.
- Added SVG assets for UI components including file, globe, next, vercel, and window icons.
- Configured TypeScript with tsconfig.json for strict type checking and module resolution.
This commit is contained in:
2026-03-11 19:13:21 +01:00
commit 01108aa23e
130 changed files with 29949 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
//! Channel routes
//!
//! CRUD for channels and broadcast/EPG endpoints.
//!
//! All routes require authentication (Bearer JWT).
use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
use domain::{DomainError, ScheduleEngineService};
use crate::{
dto::{
ChannelResponse, CreateChannelRequest, CurrentBroadcastResponse, ScheduleResponse,
ScheduledSlotResponse, UpdateChannelRequest,
},
error::ApiError,
extractors::CurrentUser,
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_channels).post(create_channel))
.route(
"/{id}",
get(get_channel).put(update_channel).delete(delete_channel),
)
.route(
"/{id}/schedule",
post(generate_schedule).get(get_active_schedule),
)
.route("/{id}/now", get(get_current_broadcast))
.route("/{id}/epg", get(get_epg))
.route("/{id}/stream", get(get_stream))
}
// ============================================================================
// Channel CRUD
// ============================================================================
async fn list_channels(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> {
let channels = state.channel_service.find_by_owner(user.id).await?;
let response: Vec<ChannelResponse> = channels.into_iter().map(Into::into).collect();
Ok(Json(response))
}
async fn create_channel(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Json(payload): Json<CreateChannelRequest>,
) -> Result<impl IntoResponse, ApiError> {
let mut channel = state
.channel_service
.create(user.id, &payload.name, &payload.timezone)
.await?;
if let Some(desc) = payload.description {
channel.description = Some(desc);
channel = state.channel_service.update(channel).await?;
}
Ok((StatusCode::CREATED, Json(ChannelResponse::from(channel))))
}
async fn get_channel(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
Ok(Json(ChannelResponse::from(channel)))
}
async fn update_channel(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
Json(payload): Json<UpdateChannelRequest>,
) -> Result<impl IntoResponse, ApiError> {
let mut channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
if let Some(name) = payload.name {
channel.name = name;
}
if let Some(desc) = payload.description {
channel.description = Some(desc);
}
if let Some(tz) = payload.timezone {
channel.timezone = tz;
}
if let Some(sc) = payload.schedule_config {
channel.schedule_config = sc;
}
if let Some(rp) = payload.recycle_policy {
channel.recycle_policy = rp;
}
channel.updated_at = Utc::now();
let channel = state.channel_service.update(channel).await?;
Ok(Json(ChannelResponse::from(channel)))
}
async fn delete_channel(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
// ChannelService::delete enforces ownership internally
state.channel_service.delete(channel_id, user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
// ============================================================================
// Schedule generation + retrieval
// ============================================================================
/// Trigger 48-hour schedule generation for a channel, starting from now.
/// Replaces any existing schedule for the same window.
async fn generate_schedule(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let schedule = state
.schedule_engine
.generate_schedule(channel_id, Utc::now())
.await?;
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
}
/// Return the currently active 48-hour schedule for a channel.
/// 404 if no schedule has been generated yet — call POST /:id/schedule first.
async fn get_active_schedule(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let schedule = state
.schedule_engine
.get_active_schedule(channel_id, Utc::now())
.await?
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
Ok(Json(ScheduleResponse::from(schedule)))
}
// ============================================================================
// Live broadcast endpoints
// ============================================================================
/// What is currently playing right now on this channel.
/// Returns 204 No Content when the channel is in a gap between blocks (no-signal).
async fn get_current_broadcast(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<Response, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let now = Utc::now();
let schedule = state
.schedule_engine
.get_active_schedule(channel_id, now)
.await?
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
match ScheduleEngineService::get_current_broadcast(&schedule, now) {
None => Ok(StatusCode::NO_CONTENT.into_response()),
Some(broadcast) => Ok(Json(CurrentBroadcastResponse {
slot: broadcast.slot.into(),
offset_secs: broadcast.offset_secs,
})
.into_response()),
}
}
/// EPG: return scheduled slots that overlap a time window.
///
/// Query params (both RFC3339, both optional):
/// - `from` — start of window (default: now)
/// - `until` — end of window (default: now + 4 hours)
#[derive(Debug, Deserialize)]
struct EpgQuery {
from: Option<String>,
until: Option<String>,
}
async fn get_epg(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
Query(params): Query<EpgQuery>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let now = Utc::now();
let from = parse_optional_dt(params.from, now)?;
let until = parse_optional_dt(params.until, now + chrono::Duration::hours(4))?;
if until <= from {
return Err(ApiError::validation("'until' must be after 'from'"));
}
let schedule = state
.schedule_engine
.get_active_schedule(channel_id, from)
.await?
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
.into_iter()
.cloned()
.map(Into::into)
.collect();
Ok(Json(slots))
}
/// Redirect to the stream URL for whatever is currently playing.
/// Returns 307 Temporary Redirect so the client fetches from the media provider directly.
/// Returns 204 No Content when the channel is in a gap (no-signal).
async fn get_stream(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<Response, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
let now = Utc::now();
let schedule = state
.schedule_engine
.get_active_schedule(channel_id, now)
.await?
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
None => return Ok(StatusCode::NO_CONTENT.into_response()),
Some(b) => b,
};
let url = state
.schedule_engine
.get_stream_url(&broadcast.slot.item.id)
.await?;
Ok(Redirect::temporary(&url).into_response())
}
// ============================================================================
// Helpers
// ============================================================================
fn require_owner(channel: &domain::Channel, user_id: Uuid) -> Result<(), ApiError> {
if channel.owner_id != user_id {
Err(ApiError::Forbidden("You don't own this channel".into()))
} else {
Ok(())
}
}
fn parse_optional_dt(s: Option<String>, default: DateTime<Utc>) -> Result<DateTime<Utc>, ApiError> {
match s {
None => Ok(default),
Some(raw) => DateTime::parse_from_rfc3339(&raw)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|_| ApiError::validation(format!("Invalid datetime '{}' — use RFC3339", raw))),
}
}