Compare commits

...

6 Commits

Author SHA1 Message Date
Flatlogic Bot
83fcbfd315 1.4 2026-04-05 16:14:46 +00:00
Flatlogic Bot
14079e71ec 1.3 2026-04-05 16:01:59 +00:00
Flatlogic Bot
d2d9f2b743 1.2 2026-04-05 15:38:30 +00:00
Flatlogic Bot
0dfcc2039f 1.1 2026-04-05 15:33:32 +00:00
Flatlogic Bot
3ea6b2dc42 2.0 2026-04-05 15:28:10 +00:00
Flatlogic Bot
bfc0c7768a Autosave: 20260405-151614 2026-04-05 15:16:14 +00:00
27 changed files with 18588 additions and 320 deletions

View File

@ -1,7 +1,6 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
@ -66,6 +65,11 @@ module.exports = class Media_assetsDBApi {
null
,
sort_order: data.sort_order
||
null
,
is_primary: data.is_primary
||
false
@ -164,6 +168,11 @@ module.exports = class Media_assetsDBApi {
duration_seconds: item.duration_seconds
||
null
,
sort_order: item.sort_order
||
null
,
is_primary: item.is_primary
@ -250,6 +259,9 @@ module.exports = class Media_assetsDBApi {
if (data.duration_seconds !== undefined) updatePayload.duration_seconds = data.duration_seconds;
if (data.sort_order !== undefined) updatePayload.sort_order = data.sort_order;
if (data.is_primary !== undefined) updatePayload.is_primary = data.is_primary;
@ -407,10 +419,6 @@ module.exports = class Media_assetsDBApi {
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{

View File

@ -0,0 +1,96 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableRows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"media_assets\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = tableRows[0].regclass_name;
if (!tableName) {
await transaction.commit();
return;
}
const columnRows = await queryInterface.sequelize.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'media_assets'
AND column_name = 'sort_order';`,
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (columnRows.length) {
await transaction.commit();
return;
}
await queryInterface.addColumn(
'media_assets',
'sort_order',
{
type: Sequelize.DataTypes.INTEGER,
allowNull: true,
},
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableRows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"media_assets\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = tableRows[0].regclass_name;
if (!tableName) {
await transaction.commit();
return;
}
const columnRows = await queryInterface.sequelize.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'media_assets'
AND column_name = 'sort_order';`,
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!columnRows.length) {
await transaction.commit();
return;
}
await queryInterface.removeColumn('media_assets', 'sort_order', { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -1,8 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const media_assets = sequelize.define(
@ -102,6 +97,13 @@ duration_seconds: {
},
sort_order: {
type: DataTypes.INTEGER,
},
is_primary: {

View File

@ -44,6 +44,7 @@ const commentsRoutes = require('./routes/comments');
const favoritesRoutes = require('./routes/favorites');
const search_logsRoutes = require('./routes/search_logs');
const publicMediaRoutes = require('./routes/public-media');
const getBaseUrl = (url) => {
@ -100,6 +101,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/public-media', publicMediaRoutes);
app.enable('trust proxy');

View File

@ -1,11 +1,20 @@
const util = require('util');
const Multer = require('multer');
const maxSize = 10 * 1024 * 1024;
let processFile = Multer({
storage: Multer.memoryStorage(),
limits: { fileSize: maxSize },
}).single("file");
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
let processFileMiddleware = util.promisify(processFile);
module.exports = processFileMiddleware;
const createProcessFile = (maxFileSize = DEFAULT_MAX_FILE_SIZE) => {
const processFile = Multer({
storage: Multer.memoryStorage(),
limits: { fileSize: maxFileSize },
}).single('file');
return util.promisify(processFile);
};
const defaultProcessFile = createProcessFile();
defaultProcessFile.createProcessFile = createProcessFile;
defaultProcessFile.DEFAULT_MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE;
module.exports = defaultProcessFile;

View File

@ -1,29 +1,38 @@
const express = require('express');
const config = require('../config');
const path = require('path');
const passport = require('passport');
const services = require('../services/file');
const router = express.Router();
router.get('/download', (req, res) => {
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
services.downloadGCloud(req, res);
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
const MEDIA_UPLOAD_MAX_FILE_SIZE = 512 * 1024 * 1024;
const getMaxFileSize = (table, field) => {
if (table === 'media_assets' && field === 'file') {
return MEDIA_UPLOAD_MAX_FILE_SIZE;
}
else {
return DEFAULT_MAX_FILE_SIZE;
};
router.get('/download', (req, res) => {
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.downloadGCloud(req, res);
} else {
services.downloadLocal(req, res);
}
});
router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => {
const fileName = `${req.params.table}/${req.params.field}`;
router.post('/upload/:table/:field', passport.authenticate('jwt', { session: false }), (req, res) => {
const { table, field } = req.params;
const fileName = `${table}/${field}`;
const maxFileSize = getMaxFileSize(table, field);
if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) {
services.uploadGCloud(fileName, req, res);
}
else {
if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) {
services.uploadGCloud(fileName, req, res, { maxFileSize });
} else {
services.uploadLocal(fileName, {
entity: null,
maxFileSize: 10 * 1024 * 1024,
maxFileSize,
folderIncludesAuthenticationUid: false,
})(req, res);
}

View File

@ -0,0 +1,582 @@
const express = require('express');
const { Op } = require('sequelize');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
const showInclude = [
{ model: db.categories, as: 'category', required: false },
{ model: db.users, as: 'owner', required: false },
{ model: db.file, as: 'poster_image', required: false },
{ model: db.file, as: 'banner_image', required: false },
];
const episodeInclude = [
{
model: db.shows,
as: 'show',
required: false,
include: [{ model: db.categories, as: 'category', required: false }],
},
{ model: db.users, as: 'uploader', required: false },
{ model: db.file, as: 'thumbnail_image', required: false },
{
model: db.media_assets,
as: 'media_assets_episode',
required: false,
include: [
{ model: db.file, as: 'file', required: false },
{ model: db.file, as: 'preview_image', required: false },
],
},
];
const streamInclude = [
{ model: db.categories, as: 'category', required: false },
{ model: db.users, as: 'host', required: false },
{ model: db.file, as: 'cover_image', required: false },
];
const categoryInclude = [{ model: db.file, as: 'cover_image', required: false }];
function parsePositiveInt(value, fallback) {
const parsed = Number.parseInt(String(value || ''), 10);
if (Number.isNaN(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
function fileUrl(fileRecord) {
if (!fileRecord) return '';
return fileRecord.publicUrl || fileRecord.privateUrl || '';
}
function primaryFile(files) {
if (!Array.isArray(files) || !files.length) return '';
return fileUrl(files[0]);
}
function personName(user) {
if (!user) return '';
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || user.email || '';
}
function serializeCategory(item) {
if (!item) return null;
return {
id: item.id,
name: item.name,
slug: item.slug,
description: item.description,
sort_order: item.sort_order,
is_featured: item.is_featured,
is_active: item.is_active,
imageUrl: primaryFile(item.cover_image),
};
}
function serializeMediaAsset(item) {
if (!item) return null;
return {
id: item.id,
asset_type: item.asset_type,
title: item.title,
source_url: item.source_url,
delivery: item.delivery,
mime_type: item.mime_type,
file_size_bytes: item.file_size_bytes,
bitrate_kbps: item.bitrate_kbps,
resolution: item.resolution,
duration_seconds: item.duration_seconds,
is_primary: item.is_primary,
fileUrl: primaryFile(item.file),
previewImageUrl: primaryFile(item.preview_image),
};
}
function pickPrimaryAsset(mediaAssets) {
if (!Array.isArray(mediaAssets) || !mediaAssets.length) return null;
return mediaAssets.find((item) => item.is_primary) || mediaAssets[0] || null;
}
function serializeShow(item) {
if (!item) return null;
return {
id: item.id,
title: item.title,
slug: item.slug,
summary: item.summary,
show_type: item.show_type,
status: item.status,
is_featured: item.is_featured,
release_year: item.release_year,
posterImageUrl: primaryFile(item.poster_image),
bannerImageUrl: primaryFile(item.banner_image),
category: serializeCategory(item.category),
owner: item.owner
? {
id: item.owner.id,
name: personName(item.owner),
email: item.owner.email,
}
: null,
};
}
function serializeEpisode(item, options = {}) {
if (!item) return null;
const mediaAssets = Array.isArray(item.media_assets_episode)
? item.media_assets_episode.map((asset) => serializeMediaAsset(asset)).filter(Boolean)
: [];
const primaryAsset = pickPrimaryAsset(mediaAssets);
const output = {
id: item.id,
title: item.title,
slug: item.slug,
description: item.description,
status: item.status,
published_at: item.published_at,
scheduled_at: item.scheduled_at,
season_number: item.season_number,
episode_number: item.episode_number,
duration_seconds: item.duration_seconds,
rating_average: item.rating_average,
views_count: item.views_count,
is_featured: item.is_featured,
thumbnailImageUrl: primaryFile(item.thumbnail_image),
show: item.show ? serializeShow(item.show) : null,
uploader: item.uploader
? {
id: item.uploader.id,
name: personName(item.uploader),
email: item.uploader.email,
}
: null,
primaryMediaAsset: primaryAsset,
};
if (options.includeMediaAssets) {
output.media_assets = mediaAssets;
}
return output;
}
const demoStreamSources = {
audioDefault: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
audioMusic: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3',
audioTalk: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3',
videoDefault: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
};
function resolvePublicStreamSource(item) {
const originalUrl = item?.stream_url || '';
if (!originalUrl) {
return {
stream_url: '',
original_stream_url: '',
is_demo_stream: false,
playback_note: '',
};
}
if (!originalUrl.includes('stream.aliyomomotmedia.com')) {
return {
stream_url: originalUrl,
original_stream_url: originalUrl,
is_demo_stream: false,
playback_note: '',
};
}
const title = String(item?.title || '').toLowerCase();
let streamUrl = demoStreamSources.audioDefault;
if (item?.stream_type === 'video') {
streamUrl = demoStreamSources.videoDefault;
} else if (title.includes('music')) {
streamUrl = demoStreamSources.audioMusic;
} else if (title.includes('culture') || title.includes('call') || title.includes('bulletin')) {
streamUrl = demoStreamSources.audioTalk;
}
return {
stream_url: streamUrl,
original_stream_url: originalUrl,
is_demo_stream: true,
playback_note: 'Demo playback is active until a production stream source is configured.',
};
}
function serializeLiveStream(item) {
if (!item) return null;
const playback = resolvePublicStreamSource(item);
return {
id: item.id,
title: item.title,
stream_url: playback.stream_url,
original_stream_url: playback.original_stream_url,
stream_type: item.stream_type,
status: item.status,
starts_at: item.starts_at,
ends_at: item.ends_at,
description: item.description,
is_featured: item.is_featured,
is_demo_stream: playback.is_demo_stream,
playback_note: playback.playback_note,
coverImageUrl: primaryFile(item.cover_image),
category: serializeCategory(item.category),
host: item.host
? {
id: item.host.id,
name: personName(item.host),
email: item.host.email,
}
: null,
};
}
function showWhere(query) {
const where = { status: 'published' };
if (query.q) {
where[Op.or] = [
{ title: { [Op.iLike]: `%${query.q}%` } },
{ summary: { [Op.iLike]: `%${query.q}%` } },
{ slug: { [Op.iLike]: `%${query.q}%` } },
];
}
if (query.categoryId) {
where.categoryId = query.categoryId;
}
return where;
}
function episodeWhere(query) {
const where = { status: 'published' };
if (query.q) {
where[Op.or] = [
{ title: { [Op.iLike]: `%${query.q}%` } },
{ description: { [Op.iLike]: `%${query.q}%` } },
{ slug: { [Op.iLike]: `%${query.q}%` } },
];
}
if (query.showId) {
where.showId = query.showId;
}
return where;
}
function streamWhere(query) {
const includeOfflineDemo = String(query.includeOfflineDemo || '').toLowerCase() === 'true';
const where = {
status: {
[Op.in]: includeOfflineDemo ? ['live', 'scheduled', 'offline'] : ['live', 'scheduled'],
},
};
if (query.q) {
where[Op.or] = [
{ title: { [Op.iLike]: `%${query.q}%` } },
{ description: { [Op.iLike]: `%${query.q}%` } },
];
}
if (query.categoryId) {
where.categoryId = query.categoryId;
}
return where;
}
function sortSerializedStreams(items) {
return [...items].sort((left, right) => {
const leftStatusPriority = left.status === 'live' ? 0 : 1;
const rightStatusPriority = right.status === 'live' ? 0 : 1;
if (leftStatusPriority !== rightStatusPriority) {
return leftStatusPriority - rightStatusPriority;
}
const leftTypePriority = left.stream_type === 'video' ? 0 : 1;
const rightTypePriority = right.stream_type === 'video' ? 0 : 1;
if (leftTypePriority !== rightTypePriority) {
return leftTypePriority - rightTypePriority;
}
const leftFeaturedPriority = left.is_featured ? 0 : 1;
const rightFeaturedPriority = right.is_featured ? 0 : 1;
if (leftFeaturedPriority !== rightFeaturedPriority) {
return leftFeaturedPriority - rightFeaturedPriority;
}
return new Date(left.starts_at || 0).getTime() - new Date(right.starts_at || 0).getTime();
});
}
router.get(
'/overview',
wrapAsync(async (req, res) => {
const categoryLimit = parsePositiveInt(req.query.categoriesLimit, 8);
const showLimit = parsePositiveInt(req.query.showsLimit, 6);
const episodeLimit = parsePositiveInt(req.query.episodesLimit, 8);
const streamLimit = parsePositiveInt(req.query.streamsLimit, 4);
const [categories, shows, episodes, liveStreams] = await Promise.all([
db.categories.findAll({
where: { is_active: true },
include: categoryInclude,
order: [
['is_featured', 'DESC'],
['sort_order', 'ASC'],
['name', 'ASC'],
],
limit: categoryLimit,
}),
db.shows.findAll({
where: { status: 'published' },
include: showInclude,
order: [
['is_featured', 'DESC'],
['release_year', 'DESC'],
['updatedAt', 'DESC'],
],
limit: showLimit,
distinct: true,
}),
db.episodes.findAll({
where: { status: 'published' },
include: episodeInclude,
order: [
['is_featured', 'DESC'],
['published_at', 'DESC'],
['createdAt', 'DESC'],
],
limit: episodeLimit,
distinct: true,
}),
db.live_streams.findAll({
where: streamWhere(req.query),
include: streamInclude,
order: [
['is_featured', 'DESC'],
['starts_at', 'ASC'],
['updatedAt', 'DESC'],
],
limit: streamLimit,
distinct: true,
}),
]);
const serializedStreams = sortSerializedStreams(liveStreams.map((item) => serializeLiveStream(item)));
res.status(200).send({
categories: categories.map((item) => serializeCategory(item)),
shows: shows.map((item) => serializeShow(item)),
episodes: episodes.map((item) => serializeEpisode(item)),
liveStreams: serializedStreams,
});
}),
);
router.get(
'/categories',
wrapAsync(async (req, res) => {
const limit = parsePositiveInt(req.query.limit, 24);
const categories = await db.categories.findAll({
where: { is_active: true },
include: categoryInclude,
order: [
['is_featured', 'DESC'],
['sort_order', 'ASC'],
['name', 'ASC'],
],
limit,
});
res.status(200).send({
rows: categories.map((item) => serializeCategory(item)),
count: categories.length,
});
}),
);
router.get(
'/shows',
wrapAsync(async (req, res) => {
const limit = parsePositiveInt(req.query.limit, 12);
const page = parsePositiveInt(req.query.page, 0);
const query = {
where: showWhere(req.query),
include: showInclude,
order: [
['is_featured', 'DESC'],
['release_year', 'DESC'],
['updatedAt', 'DESC'],
],
limit,
offset: page * limit,
distinct: true,
};
const { rows, count } = await db.shows.findAndCountAll(query);
res.status(200).send({
rows: rows.map((item) => serializeShow(item)),
count,
page,
limit,
});
}),
);
router.get(
'/shows/:id',
wrapAsync(async (req, res) => {
const show = await db.shows.findOne({
where: {
id: req.params.id,
status: 'published',
},
include: showInclude,
});
if (!show) {
return res.status(404).send({ message: 'Show not found' });
}
const episodes = await db.episodes.findAll({
where: {
showId: show.id,
status: 'published',
},
include: episodeInclude,
order: [
['season_number', 'ASC'],
['episode_number', 'ASC'],
['published_at', 'DESC'],
['createdAt', 'DESC'],
],
distinct: true,
});
return res.status(200).send({
...serializeShow(show),
episodes: episodes.map((item) => serializeEpisode(item)),
});
}),
);
router.get(
'/episodes',
wrapAsync(async (req, res) => {
const limit = parsePositiveInt(req.query.limit, 16);
const page = parsePositiveInt(req.query.page, 0);
const include = [...episodeInclude];
if (req.query.categoryId) {
include[0] = {
...include[0],
required: true,
where: { categoryId: req.query.categoryId },
};
}
const { rows, count } = await db.episodes.findAndCountAll({
where: episodeWhere(req.query),
include,
order: [
['is_featured', 'DESC'],
['published_at', 'DESC'],
['createdAt', 'DESC'],
],
limit,
offset: page * limit,
distinct: true,
});
res.status(200).send({
rows: rows.map((item) => serializeEpisode(item)),
count,
page,
limit,
});
}),
);
router.get(
'/episodes/:id',
wrapAsync(async (req, res) => {
const episode = await db.episodes.findOne({
where: {
id: req.params.id,
status: 'published',
},
include: episodeInclude,
distinct: true,
});
if (!episode) {
return res.status(404).send({ message: 'Episode not found' });
}
return res.status(200).send(serializeEpisode(episode, { includeMediaAssets: true }));
}),
);
router.get(
'/streams',
wrapAsync(async (req, res) => {
const limit = parsePositiveInt(req.query.limit, 12);
const page = parsePositiveInt(req.query.page, 0);
const { rows, count } = await db.live_streams.findAndCountAll({
where: streamWhere(req.query),
include: streamInclude,
order: [
['is_featured', 'DESC'],
['starts_at', 'ASC'],
['updatedAt', 'DESC'],
],
limit,
offset: page * limit,
distinct: true,
});
const serializedRows = sortSerializedStreams(rows.map((item) => serializeLiveStream(item)));
res.status(200).send({
rows: serializedRows,
count,
page,
limit,
});
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -78,6 +78,11 @@ const uploadLocal = (
});
form.on('error', function (err) {
if (err && /maxFileSize|maxFieldsSize|maxTotalFileSize/i.test(err.message || '')) {
res.status(413).send({ message: 'File is too large for this upload.' });
return;
}
res.status(500).send(err);
});
}
@ -91,8 +96,11 @@ const downloadLocal = async (req, res) => {
res.download(path.join(config.uploadDir, privateUrl));
}
const initGCloud = () => {
const processFile = require("../middlewares/upload");
const initGCloud = (maxFileSize) => {
const defaultProcessFile = require('../middlewares/upload');
const processFile = maxFileSize
? defaultProcessFile.createProcessFile(maxFileSize)
: defaultProcessFile;
const { Storage } = require("@google-cloud/storage");
const crypto = require('crypto')
@ -112,9 +120,10 @@ const initGCloud = () => {
return {hash, bucket, processFile};
}
const uploadGCloud = async (folder, req, res) => {
const uploadGCloud = async (folder, req, res, options = {}) => {
try {
const {hash, bucket, processFile} = initGCloud();
const { maxFileSize } = options;
const {hash, bucket, processFile} = initGCloud(maxFileSize);
await processFile(req, res);
let buffer = await req.file.buffer;
let filename = await req.body.filename;
@ -155,6 +164,11 @@ const uploadGCloud = async (folder, req, res) => {
} catch (err) {
console.log(err);
if (err && err.code === 'LIMIT_FILE_SIZE') {
res.status(413).send({ message: 'File is too large for this upload.' });
return;
}
res.status(500).send({
message: `Could not upload the file. ${err}`
});

12121
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@
"dayjs": "^1.11.10",
"file-saver": "^2.0.5",
"formik": "^2.4.5",
"hls.js": "^1.6.15",
"html2canvas": "^1.4.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -0,0 +1,257 @@
import { mdiPlayCircleOutline, mdiRadio } from '@mdi/js';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { isDirectMedia, isHlsUrl, toYouTubeEmbed } from '../helpers/publicMedia';
import BaseIcon from './BaseIcon';
type PublicMediaPlayerProps = {
title: string;
url?: string;
fallbackUrl?: string;
type?: string;
posterUrl?: string;
emptyMessage?: string;
externalMessage?: string;
externalLabel?: string;
};
let hlsLibraryPromise: Promise<any> | null = null;
function loadHlsLibrary() {
if (!hlsLibraryPromise) {
hlsLibraryPromise = import('hls.js')
.then((module) => module.default || module)
.catch((error) => {
hlsLibraryPromise = null;
throw error;
});
}
return hlsLibraryPromise;
}
export default function PublicMediaPlayer({
title,
url = '',
fallbackUrl = '',
type = 'video',
posterUrl = '',
emptyMessage = 'No public playback source is attached yet.',
externalMessage = 'This stream uses an external playback URL. Open it in a new tab.',
externalLabel = 'Open source',
}: PublicMediaPlayerProps) {
const mediaRef = useRef<HTMLVideoElement | HTMLAudioElement | null>(null);
const [hlsState, setHlsState] = useState<'idle' | 'loading' | 'ready' | 'fallback' | 'unsupported' | 'error'>('idle');
const [hlsErrorMessage, setHlsErrorMessage] = useState('');
const embedUrl = useMemo(() => toYouTubeEmbed(url || fallbackUrl), [fallbackUrl, url]);
const hlsUrl = useMemo(() => {
if (isHlsUrl(url)) return url;
if (isHlsUrl(fallbackUrl)) return fallbackUrl;
return '';
}, [fallbackUrl, url]);
const directUrl = useMemo(() => {
if (url && !isHlsUrl(url) && isDirectMedia(url, type)) return url;
if (fallbackUrl && !isHlsUrl(fallbackUrl) && isDirectMedia(fallbackUrl, type)) return fallbackUrl;
return '';
}, [fallbackUrl, type, url]);
const externalUrl = url || fallbackUrl || '';
useEffect(() => {
const mediaElement = mediaRef.current;
if (!mediaElement || !hlsUrl || embedUrl) {
setHlsState('idle');
setHlsErrorMessage('');
return undefined;
}
let isCancelled = false;
let hlsInstance: any = null;
const applyFallbackUrl = (message = '') => {
if (!mediaRef.current || !directUrl) {
return false;
}
mediaRef.current.src = directUrl;
setHlsState('fallback');
setHlsErrorMessage(message);
return true;
};
const attachHls = async () => {
setHlsState('loading');
setHlsErrorMessage('');
const canPlayHlsNatively =
typeof mediaElement.canPlayType === 'function' &&
(mediaElement.canPlayType('application/vnd.apple.mpegurl') !== '' || mediaElement.canPlayType('application/x-mpegURL') !== '');
if (canPlayHlsNatively) {
mediaElement.src = hlsUrl;
setHlsState('ready');
return;
}
if (type !== 'video') {
if (!applyFallbackUrl('Native HLS is unavailable in this browser, so direct audio playback is being used instead.')) {
setHlsState('unsupported');
}
return;
}
try {
const Hls = await loadHlsLibrary();
if (isCancelled || !mediaRef.current) {
return;
}
if (!Hls?.isSupported?.()) {
if (!applyFallbackUrl('HLS playback is not supported here, so the direct fallback source is being used instead.')) {
setHlsState('unsupported');
}
return;
}
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: true,
});
hlsInstance.loadSource(hlsUrl);
hlsInstance.attachMedia(mediaRef.current);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
if (!isCancelled) {
setHlsState('ready');
}
});
hlsInstance.on(Hls.Events.ERROR, (_event: any, data: any) => {
if (!data?.fatal || isCancelled) {
return;
}
console.error('Public HLS playback failed:', data);
if (applyFallbackUrl('The HLS stream could not be loaded, so the direct fallback source is being used instead.')) {
hlsInstance.destroy();
hlsInstance = null;
return;
}
setHlsState('error');
setHlsErrorMessage('The live HLS stream could not be loaded in this browser.');
hlsInstance.destroy();
hlsInstance = null;
});
} catch (error) {
console.error('Failed to initialize HLS playback:', error);
if (!applyFallbackUrl('The browser player could not initialize HLS, so the direct fallback source is being used instead.')) {
setHlsState('error');
setHlsErrorMessage('The browser player could not initialize HLS playback.');
}
}
};
attachHls();
return () => {
isCancelled = true;
if (hlsInstance) {
hlsInstance.destroy();
}
if (mediaElement) {
mediaElement.removeAttribute('src');
mediaElement.load?.();
}
};
}, [directUrl, embedUrl, hlsUrl, type]);
if (embedUrl) {
return (
<div className='relative aspect-video overflow-hidden rounded-[24px] border border-white/10 bg-black'>
<iframe
src={embedUrl}
title={title}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
className='h-full w-full'
/>
</div>
);
}
if (type === 'audio' && (hlsUrl || directUrl)) {
return (
<div className='rounded-[24px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.15),_transparent_30%),linear-gradient(135deg,_rgba(8,17,34,0.96),_rgba(15,23,42,0.9),_rgba(30,41,59,0.82))] p-8'>
<div className='flex flex-col gap-6 md:flex-row md:items-center md:justify-between'>
<div>
<div className='inline-flex rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs uppercase tracking-[0.22em] text-cyan-100'>
{hlsUrl ? 'HLS-ready audio' : 'Audio live'}
</div>
<h3 className='mt-4 text-2xl font-semibold text-white'>Press play to start listening</h3>
<p className='mt-3 max-w-xl text-sm leading-6 text-slate-300'>
{hlsUrl ? 'This player will use the HLS source when possible and keep a direct audio fallback ready for browsers that need it.' : 'This stream is configured for direct browser playback, so you can listen immediately without leaving the page.'}
</p>
</div>
<div className='flex h-24 w-24 items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-300/10'>
<BaseIcon path={mdiRadio} size={40} className='text-cyan-200' />
</div>
</div>
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} controls className='mt-6 w-full'>
{directUrl ? <source src={directUrl} /> : null}
Your browser does not support audio playback.
</audio>
{hlsState === 'loading' ? <p className='mt-4 text-sm text-cyan-100'>Preparing the live HLS audio feed</p> : null}
{hlsErrorMessage ? <p className='mt-4 text-sm text-amber-100'>{hlsErrorMessage}</p> : null}
</div>
);
}
if (hlsUrl || directUrl) {
return (
<div className='relative aspect-video overflow-hidden rounded-[24px] border border-white/10 bg-black'>
<video
ref={mediaRef as React.RefObject<HTMLVideoElement>}
controls
playsInline
poster={posterUrl || undefined}
className='h-full w-full bg-black'
>
{directUrl ? <source src={directUrl} /> : null}
Your browser does not support video playback.
</video>
{hlsState === 'loading' ? (
<div className='pointer-events-none absolute inset-x-4 top-4 rounded-full border border-cyan-300/20 bg-slate-950/80 px-4 py-2 text-sm text-cyan-100'>
Preparing HLS playback
</div>
) : null}
{hlsErrorMessage ? (
<div className='pointer-events-none absolute inset-x-4 bottom-4 rounded-[18px] border border-amber-400/20 bg-slate-950/85 px-4 py-3 text-sm text-amber-100'>
{hlsErrorMessage}
</div>
) : null}
</div>
);
}
if (externalUrl) {
return (
<div className='flex aspect-video flex-col items-center justify-center gap-4 rounded-[24px] border border-dashed border-white/15 bg-slate-950/60 px-6 text-center'>
<BaseIcon path={mdiPlayCircleOutline} size={48} className='text-cyan-300' />
<p className='max-w-md text-sm leading-6 text-slate-300'>{externalMessage}</p>
<a href={externalUrl} target='_blank' rel='noreferrer' className='rounded-full bg-white px-5 py-2 text-sm font-medium text-slate-950 transition hover:bg-cyan-100'>
{externalLabel}
</a>
</div>
);
}
return (
<div className='flex aspect-video flex-col items-center justify-center gap-4 rounded-[24px] border border-dashed border-white/15 bg-slate-950/60 px-6 text-center'>
<BaseIcon path={mdiPlayCircleOutline} size={48} className='text-slate-400' />
<p className='max-w-md text-sm leading-6 text-slate-300'>{emptyMessage}</p>
</div>
);
}

View File

@ -0,0 +1,118 @@
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiOpenInNew } from '@mdi/js';
import React, { useMemo } from 'react';
import { formatMediaDate, getLivePlaybackUrls, getPublicStatusClass, humanizeMediaKind } from '../helpers/publicMedia';
import BaseButton from './BaseButton';
import BaseIcon from './BaseIcon';
import PublicMediaPlayer from './PublicMediaPlayer';
type PublicNowPlayingDockProps = {
stream: any;
isMinimized?: boolean;
liveRoomHref?: string;
onToggleMinimized: () => void;
onClose: () => void;
};
export default function PublicNowPlayingDock({
stream,
isMinimized = false,
liveRoomHref = '/watch/live',
onToggleMinimized,
onClose,
}: PublicNowPlayingDockProps) {
const playback = useMemo(() => getLivePlaybackUrls(stream), [stream]);
if (!stream) return null;
return (
<div className='fixed inset-x-4 bottom-4 z-40 md:left-auto md:right-4 md:w-[420px]'>
<div className='overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,_rgba(8,17,34,0.96),_rgba(15,23,42,0.97),_rgba(30,41,59,0.95))] shadow-2xl shadow-cyan-950/40 backdrop-blur'>
<div className='border-b border-white/10 px-4 py-4 sm:px-5'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(stream.status)}`}>
{stream.status || 'live'}
</span>
<span className='rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] uppercase tracking-[0.18em] text-cyan-100'>
{humanizeMediaKind(stream.stream_type)}
</span>
</div>
<h3 className='mt-3 line-clamp-2 text-lg font-semibold text-white'>{stream.title || 'Now playing'}</h3>
<p className='mt-1 text-xs text-slate-400'>
{stream.category?.name || 'Live program'} {formatMediaDate(stream.starts_at)}
</p>
</div>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={onToggleMinimized}
className='inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-200 transition hover:border-cyan-300/40 hover:text-white'
aria-label={isMinimized ? 'Expand now playing dock' : 'Minimize now playing dock'}
>
<BaseIcon path={isMinimized ? mdiChevronUp : mdiChevronDown} size={20} />
</button>
<button
type='button'
onClick={onClose}
className='inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-200 transition hover:border-rose-300/40 hover:text-white'
aria-label='Close now playing dock'
>
<BaseIcon path={mdiClose} size={20} />
</button>
</div>
</div>
</div>
{!isMinimized ? (
<div className='space-y-4 p-4 sm:p-5'>
<PublicMediaPlayer
title={stream.title || 'Now playing'}
url={playback.primaryUrl}
fallbackUrl={playback.fallbackUrl}
type={stream.stream_type}
posterUrl={stream.coverImageUrl}
externalMessage='This stream opens in a separate player or external broadcast window.'
externalLabel='Open source'
emptyMessage='No playback source is available for this stream yet.'
/>
<div className='flex flex-wrap gap-2 text-xs text-slate-300'>
{stream.host?.name ? (
<span className='rounded-full border border-white/10 bg-white/5 px-3 py-1.5'>Host: {stream.host.name}</span>
) : null}
{stream.is_demo_stream ? (
<span className='rounded-full border border-amber-400/20 bg-amber-400/10 px-3 py-1.5 text-amber-100'>Demo source enabled</span>
) : null}
{stream.playback_note ? (
<span className='rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1.5 text-cyan-100'>{stream.playback_note}</span>
) : null}
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href={liveRoomHref} color='info' label='Open full live room' className='shadow-lg shadow-cyan-500/20' />
{playback.externalUrl ? (
<BaseButton
href={playback.externalUrl}
target='_blank'
color='whiteDark'
outline
icon={mdiOpenInNew}
label='Open source'
className='border-white/20 bg-transparent text-white hover:bg-white/10'
/>
) : null}
</div>
</div>
) : (
<div className='flex items-center justify-between gap-3 px-4 py-3 text-sm text-slate-300 sm:px-5'>
<span className='line-clamp-1'>{stream.description || 'Mini player minimized. Re-open to continue watching or listening.'}</span>
<BaseButton href={liveRoomHref} color='whiteDark' outline label='Live room' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,192 @@
export function formatMediaDate(value?: string | Date | null, withTime = true) {
if (!value) return 'Not scheduled yet';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Not scheduled yet';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
...(withTime
? {
hour: 'numeric',
minute: '2-digit',
}
: {}),
}).format(date);
}
export function formatDuration(seconds?: number | null) {
if (!seconds || Number.isNaN(Number(seconds))) return 'Duration not set';
const totalSeconds = Number(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainder = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${remainder}s`;
}
return `${remainder}s`;
}
export function humanizeMediaKind(value?: string | null) {
if (!value) return 'Media';
return String(value).replace(/_/g, ' ');
}
export function toYouTubeEmbed(url?: string | null) {
if (!url) return '';
try {
const parsed = new URL(url);
if (parsed.hostname.includes('youtu.be')) {
const id = parsed.pathname.replace('/', '');
return id ? `https://www.youtube.com/embed/${id}` : '';
}
if (parsed.hostname.includes('youtube.com')) {
if (parsed.pathname.includes('/embed/')) return url;
const id = parsed.searchParams.get('v');
return id ? `https://www.youtube.com/embed/${id}` : '';
}
} catch (error) {
return '';
}
return '';
}
export function isHlsUrl(url?: string | null) {
if (!url) return false;
return url.toLowerCase().includes('.m3u8');
}
export function isDirectMedia(url?: string | null, type?: string | null) {
if (!url) return false;
const lowered = url.toLowerCase();
if (type === 'audio') {
return (
lowered.endsWith('.mp3') ||
lowered.endsWith('.aac') ||
lowered.endsWith('.wav') ||
lowered.endsWith('.ogg') ||
lowered.includes('.m3u8')
);
}
return (
lowered.endsWith('.mp4') ||
lowered.endsWith('.webm') ||
lowered.endsWith('.mov') ||
lowered.includes('.m3u8')
);
}
export function getPublicStreamQueryId(value?: string | string[] | null) {
if (Array.isArray(value)) {
return value[0] || '';
}
return typeof value === 'string' ? value : '';
}
export function buildPublicStreamHref(path: string, streamId?: string | null) {
if (!streamId) return path;
const searchParams = new URLSearchParams({ stream: streamId });
return `${path}?${searchParams.toString()}`;
}
export function findStreamById(items: any[] = [], streamId?: string | null) {
const list = Array.isArray(items) ? items : [];
if (!streamId) {
return null;
}
return list.find((item: any) => item?.id === streamId) || null;
}
export function getLivePlaybackUrls(stream: any) {
const originalUrl = stream?.original_stream_url || '';
const streamUrl = stream?.stream_url || '';
const hlsUrl = isHlsUrl(originalUrl) ? originalUrl : isHlsUrl(streamUrl) ? streamUrl : '';
const fallbackUrl = hlsUrl && streamUrl !== hlsUrl ? streamUrl : '';
const primaryUrl = hlsUrl || streamUrl || originalUrl;
return {
primaryUrl,
hlsUrl,
fallbackUrl,
externalUrl: originalUrl || streamUrl,
};
}
export function getPrimaryPlaybackUrls(episode: any) {
const asset = episode?.primaryMediaAsset;
const fileUrl = asset?.fileUrl || '';
const sourceUrl = asset?.source_url || '';
const hlsUrl = isHlsUrl(sourceUrl) ? sourceUrl : isHlsUrl(fileUrl) ? fileUrl : '';
const fallbackUrl = hlsUrl && fileUrl && fileUrl !== hlsUrl ? fileUrl : '';
const primaryUrl = hlsUrl || fileUrl || sourceUrl;
return {
primaryUrl,
hlsUrl,
fallbackUrl,
externalUrl: sourceUrl || fileUrl,
};
}
export function getPrimaryPlaybackUrl(episode: any) {
return getPrimaryPlaybackUrls(episode).primaryUrl;
}
export function getPrimaryPlaybackType(episode: any) {
const assetType = episode?.primaryMediaAsset?.asset_type;
if (assetType === 'audio') return 'audio';
if (assetType === 'video') return 'video';
return 'video';
}
export function pickPreferredLiveStream(items: any[] = []) {
const list = Array.isArray(items) ? items : [];
return (
list.find((item: any) => item?.status === 'live' && item?.stream_type === 'video') ||
list.find((item: any) => item?.stream_type === 'video') ||
list.find((item: any) => item?.status === 'live') ||
list.find((item: any) => item?.is_featured) ||
list[0] ||
null
);
}
export function resolvePreferredPublicStream(items: any[] = [], streamId?: string | null) {
return findStreamById(items, streamId) || pickPreferredLiveStream(items);
}
const statusStyles: Record<string, string> = {
live: 'border border-red-500/20 bg-red-500/10 text-red-300',
scheduled: 'border border-amber-500/20 bg-amber-500/10 text-amber-200',
published: 'border border-emerald-500/20 bg-emerald-500/10 text-emerald-200',
offline: 'border border-slate-500/20 bg-slate-500/10 text-slate-200',
draft: 'border border-slate-500/20 bg-slate-500/10 text-slate-200',
archived: 'border border-slate-500/20 bg-slate-500/10 text-slate-200',
};
export function getPublicStatusClass(status?: string | null) {
return statusStyles[String(status || '').toLowerCase()] || statusStyles.offline;
}

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -7,6 +7,13 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/media-center',
label: 'Media center',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBroadcast' in icon ? icon['mdiBroadcast' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
},
{
href: '/users/users-list',

View File

@ -47,7 +47,10 @@ const menuNavBar: MenuNavBarItem[] = [
]
export const webPagesNavBar = [
{ href: '/watch', label: 'Watch' },
{ href: '/watch/live', label: 'Live' },
{ href: '/watch/shows', label: 'Shows' },
{ href: '/watch/episodes', label: 'Episodes' },
];
export default menuNavBar

View File

@ -1,166 +1,521 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import PublicNowPlayingDock from '../components/PublicNowPlayingDock';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import { buildPublicStreamHref, formatMediaDate, getPublicStatusClass, getPublicStreamQueryId, humanizeMediaKind, resolvePreferredPublicStream } from '../helpers/publicMedia';
const contentPillars = [
{
title: 'Live shows',
text: 'Spotlight live broadcasts, premiere events, and scheduled streams from one branded destination.',
},
{
title: 'News & talk',
text: 'Organize headline stories, interviews, and commentary so visitors can find the latest updates fast.',
},
{
title: 'Comedy & culture',
text: 'Publish personality-driven segments, skits, and cultural programs that keep the portal lively.',
},
{
title: 'Music & audio',
text: 'Mix radio-style sessions, podcasts, and playlists into the same editorial workflow.',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const workflowSteps = [
{
step: '01',
title: 'Program the lineup',
text: 'Create shows, assign categories, and keep your content structure tidy for editors and hosts.',
},
{
step: '02',
title: 'Publish episodes',
text: 'Upload episode entries, schedule releases, and keep audiences moving from one program to the next.',
},
{
step: '03',
title: 'Go live instantly',
text: 'Feature active streams, surface upcoming sessions, and keep the control room ready for airtime.',
},
];
const title = 'Aliyo Momot Media'
const portalHighlights = [
'Public-facing brand presence with a strong hero and clear calls to action',
'Authenticated media control room for browsing live streams, shows, and episodes',
'Existing admin CRUD for shows, episodes, categories, streams, pages, and assets',
'Fast search-led workflow to move from discovery to detail pages in a few clicks',
];
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
const fallbackTickerItems = [
{ id: 'fallback-1', title: 'Prime Time Studio', stream_type: 'video', status: 'live', starts_at: '2026-04-05T18:00:00.000Z' },
{ id: 'fallback-2', title: 'Evening Bulletin', stream_type: 'audio', status: 'scheduled', starts_at: '2026-04-05T18:45:00.000Z' },
{ id: 'fallback-3', title: 'Culture Call-In', stream_type: 'audio', status: 'live', starts_at: '2026-04-05T19:30:00.000Z' },
{ id: 'fallback-4', title: 'Weekend Music Hour', stream_type: 'video', status: 'scheduled', starts_at: '2026-04-05T20:00:00.000Z' },
];
export default function HomePage() {
const router = useRouter();
const [tickerStreams, setTickerStreams] = useState<any[]>([]);
const [selectedTickerStreamId, setSelectedTickerStreamId] = useState('');
const [hasHydrated, setHasHydrated] = useState(false);
const [isNowPlayingVisible, setIsNowPlayingVisible] = useState(false);
const [isNowPlayingMinimized, setIsNowPlayingMinimized] = useState(false);
const [hasDismissedNowPlaying, setHasDismissedNowPlaying] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
useEffect(() => {
let isMounted = true;
const loadTicker = async () => {
try {
const response = await axios.get('/public-media/streams', {
params: {
limit: 8,
includeOfflineDemo: true,
},
});
if (isMounted) {
setTickerStreams(response.data?.rows || []);
}
} catch (error) {
console.error('Failed to load landing page live ticker:', error);
}
};
loadTicker();
return () => {
isMounted = false;
};
}, []);
const tickerItems = useMemo(() => {
if (!hasHydrated) {
return [];
}
return tickerStreams.length ? tickerStreams : fallbackTickerItems;
}, [hasHydrated, tickerStreams]);
const queryStreamId = useMemo(() => getPublicStreamQueryId(router.query.stream), [router.query.stream]);
useEffect(() => {
const preferredStream = resolvePreferredPublicStream(tickerItems, queryStreamId);
if (!preferredStream) {
return;
}
const nextSelectedStreamId = preferredStream.id || '';
if (nextSelectedStreamId !== selectedTickerStreamId) {
setSelectedTickerStreamId(nextSelectedStreamId);
}
}, [queryStreamId, selectedTickerStreamId, tickerItems]);
const activeTickerStream = useMemo(() => {
return resolvePreferredPublicStream(tickerItems, selectedTickerStreamId);
}, [selectedTickerStreamId, tickerItems]);
const selectedLiveRoomStreamId = useMemo(() => {
if (!activeTickerStream?.id) {
return '';
}
return tickerStreams.some((item) => item.id === activeTickerStream.id) ? activeTickerStream.id : '';
}, [activeTickerStream, tickerStreams]);
const liveRoomHref = useMemo(() => buildPublicStreamHref('/watch/live', selectedLiveRoomStreamId), [selectedLiveRoomStreamId]);
useEffect(() => {
if (!router.isReady) {
return;
}
const currentQueryStreamId = getPublicStreamQueryId(router.query.stream);
if (selectedLiveRoomStreamId && currentQueryStreamId !== selectedLiveRoomStreamId) {
router.replace(
{
pathname: router.pathname,
query: { ...router.query, stream: selectedLiveRoomStreamId },
},
undefined,
{ shallow: true, scroll: false },
);
return;
}
if (!selectedLiveRoomStreamId && currentQueryStreamId) {
const nextQuery = { ...router.query };
delete nextQuery.stream;
router.replace(
{
pathname: router.pathname,
query: nextQuery,
},
undefined,
{ shallow: true, scroll: false },
);
}
}, [router, selectedLiveRoomStreamId]);
useEffect(() => {
if (!tickerStreams.length || hasDismissedNowPlaying || isNowPlayingVisible || activeTickerStream?.status !== 'live') {
return;
}
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
}, [activeTickerStream, hasDismissedNowPlaying, isNowPlayingVisible, tickerStreams.length]);
const handleTickerSelect = (stream: any) => {
setSelectedTickerStreamId(stream?.id || '');
setHasDismissedNowPlaying(false);
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Aliyo Momot')}</title>
<meta
name='description'
content='Aliyo Momot television and media portal with branded public entry, editorial control room, and content management workflow.'
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Aliyo Momot Media app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<main className='min-h-screen bg-[#070b1a] text-white'>
<section className='relative overflow-hidden'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.28),_transparent_34%),radial-gradient(circle_at_80%_20%,_rgba(168,85,247,0.24),_transparent_28%),linear-gradient(135deg,_#070b1a_0%,_#0f172a_48%,_#111827_100%)]' />
<div className='absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-cyan-300/80 to-transparent' />
<div className='relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-16 pt-6 lg:px-10'>
<header className='flex flex-col gap-4 rounded-[28px] border border-white/10 bg-white/5 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between'>
<Link href='/' className='flex items-center gap-3'>
<span className='flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-400 to-fuchsia-500 text-lg font-semibold text-slate-950'>
AM
</span>
<div>
<p className='text-xs uppercase tracking-[0.35em] text-cyan-200/80'>Aliyo Momot</p>
<p className='text-lg font-semibold text-white'>Television & Media</p>
</div>
</Link>
<nav className='flex flex-wrap items-center gap-3 text-sm text-slate-200'>
<a href='#programming' className='transition hover:text-white'>Programming</a>
<a href='#workflow' className='transition hover:text-white'>Workflow</a>
<a href='#portal' className='transition hover:text-white'>Portal</a>
<Link href={liveRoomHref} className='rounded-full border border-white/15 px-4 py-2 transition hover:border-cyan-300 hover:text-white'>
Watch live
</Link>
<Link href='/login' className='rounded-full border border-white/15 px-4 py-2 transition hover:border-cyan-300 hover:text-white'>
Login
</Link>
<Link href='/dashboard' className='rounded-full bg-white px-4 py-2 font-medium text-slate-950 transition hover:bg-cyan-100'>
Admin interface
</Link>
</nav>
</header>
<div className='live-ticker-wrap mt-5 overflow-hidden rounded-full border border-white/10 bg-white/5 px-3 py-3 backdrop-blur'>
<div className='live-ticker-track flex min-w-max items-center gap-3 pr-3'>
{[...tickerItems, ...tickerItems].map((item, index) => {
const isSelected = item.id === activeTickerStream?.id;
return (
<button
key={`${item.id || item.title}-${index}`}
type='button'
onClick={() => handleTickerSelect(item)}
aria-pressed={isSelected}
className={`inline-flex shrink-0 items-center gap-3 rounded-full border px-4 py-2 text-sm transition ${
isSelected
? 'border-cyan-300/60 bg-cyan-300/15 text-white'
: 'border-white/10 bg-slate-950/70 text-slate-200 hover:border-cyan-300/40 hover:text-white'
}`}
>
<span className='inline-flex items-center gap-2'>
<span className='h-2.5 w-2.5 rounded-full bg-red-400 animate-pulse' />
<span className={`rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(item.status)}`}>
{item.status}
</span>
</span>
<span className='font-medium text-white'>{item.title}</span>
<span className='text-xs uppercase tracking-[0.18em] text-cyan-200/80'>{humanizeMediaKind(item.stream_type)}</span>
<span className='text-xs text-slate-400'>{formatMediaDate(item.starts_at)}</span>
</button>
);
})}
</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<div className='grid flex-1 items-center gap-10 py-14 lg:grid-cols-[1.05fr_0.95fr] lg:py-20'>
<div className='max-w-3xl'>
<div className='mb-6 inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100'>
<span className='h-2 w-2 rounded-full bg-emerald-400' />
A branded digital home for live, on-demand, and editorial programming
</div>
<h1 className='text-5xl font-semibold tracking-tight text-white sm:text-6xl'>
Launch a polished broadcast portal for <span className='text-cyan-300'>Aliyo Momot</span>.
</h1>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-300'>
This first iteration turns the seed app into a modern television and media experience: a public-facing landing page,
a clear route into the admin interface, and a control room for browsing shows, episodes, and live streams in one place.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton href={liveRoomHref} color='info' label='Watch live now' className='shadow-lg shadow-cyan-500/20' />
{activeTickerStream ? (
<BaseButton
color='whiteDark'
outline
label={isNowPlayingVisible ? 'Resume mini player' : 'Quick play selected stream'}
onClick={() => {
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
}}
className='border-white/20 bg-white/5 text-white hover:bg-white/10'
/>
) : null}
<BaseButton href='/dashboard' color='whiteDark' outline label='Open admin interface' className='border-white/20 bg-white/5 text-white hover:bg-white/10' />
<BaseButton href='/media-center' color='whiteDark' outline label='Enter media control room' className='border-white/20 bg-white/5 text-white hover:bg-white/10' />
</div>
{activeTickerStream ? (
<div className='mt-6 rounded-[26px] border border-white/10 bg-white/5 p-5 backdrop-blur'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-cyan-200/80'>Selected from live ticker</p>
<h2 className='mt-2 text-2xl font-semibold text-white'>{activeTickerStream.title}</h2>
<div className='mt-3 flex flex-wrap items-center gap-2 text-sm text-slate-300'>
<span className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(activeTickerStream.status)}`}>
{activeTickerStream.status}
</span>
<span>{humanizeMediaKind(activeTickerStream.stream_type)}</span>
<span className='text-slate-500'></span>
<span>{formatMediaDate(activeTickerStream.starts_at)}</span>
</div>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton
color='info'
label={isNowPlayingVisible ? 'Open now playing' : 'Play in mini player'}
onClick={() => {
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
}}
/>
<BaseButton href={liveRoomHref} color='whiteDark' outline label='Open live room' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
</div>
</div>
) : null}
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Experience</p>
<p className='mt-3 text-2xl font-semibold'>Modern</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>Youthful gradients, editorial cards, and clear navigation for fast discovery.</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Workflow</p>
<p className='mt-3 text-2xl font-semibold'>End-to-end</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>Move from content overview to detail pages and CRUD creation flows in a few clicks.</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Audience</p>
<p className='mt-3 text-2xl font-semibold'>Viewer-ready</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>Designed to feel like a media portal while keeping the admin capabilities close at hand.</p>
</div>
</div>
</div>
</div>
<div className='relative'>
<div className='absolute -left-6 top-10 h-24 w-24 rounded-full bg-fuchsia-500/30 blur-3xl' />
<div className='absolute -right-6 bottom-10 h-28 w-28 rounded-full bg-cyan-400/20 blur-3xl' />
<div className='relative overflow-hidden rounded-[32px] border border-white/10 bg-[#081122]/80 p-6 shadow-2xl shadow-cyan-950/40 backdrop-blur'>
<div className='flex items-center justify-between'>
<div>
<p className='text-sm uppercase tracking-[0.3em] text-cyan-200/80'>Tonight&apos;s look</p>
<h2 className='mt-2 text-2xl font-semibold'>Aliyo Momot portal preview</h2>
</div>
<span className='rounded-full border border-emerald-400/30 bg-emerald-400/10 px-3 py-1 text-xs font-medium text-emerald-200'>
Ready for launch
</span>
</div>
<div className='mt-6 grid gap-4'>
<div className='rounded-[28px] bg-gradient-to-br from-cyan-500/20 via-slate-900 to-fuchsia-500/15 p-5'>
<div className='flex items-center justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.25em] text-cyan-100/70'>Featured live</p>
<p className='mt-2 text-2xl font-semibold'>Prime Time Studio</p>
</div>
<span className='rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-200'>LIVE</span>
</div>
<div className='mt-6 aspect-video rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,_rgba(34,211,238,0.14),_rgba(15,23,42,0.94)_45%,_rgba(168,85,247,0.16))] p-4'>
<div className='flex h-full flex-col justify-between rounded-[20px] border border-white/10 bg-slate-950/70 p-4'>
<div className='flex items-center justify-between text-sm text-slate-300'>
<span>Studio feed</span>
<span>1080p Stereo</span>
</div>
<div className='flex flex-1 items-center justify-center'>
<div className='flex h-20 w-20 items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-300/10'>
<span className='ml-1 text-3xl text-cyan-200'></span>
</div>
</div>
<div className='grid grid-cols-3 gap-3 text-xs text-slate-300'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>Live queue</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>News desk</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>Music hour</div>
</div>
</div>
</div>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
<div className='rounded-[24px] border border-white/10 bg-white/5 p-5'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-400'>Search-led discovery</p>
<p className='mt-3 text-lg font-semibold'>Find a show or episode fast</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>The internal control room adds quick filters and content cards so editors can jump straight to the right record.</p>
</div>
<div className='rounded-[24px] border border-white/10 bg-white/5 p-5'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-400'>Editorial actions</p>
<p className='mt-3 text-lg font-semibold'>Upload, schedule, publish</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>Shows, episodes, streams, and supporting pages stay connected to the existing admin workflow.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id='programming' className='border-t border-white/5 bg-[#08101f]'>
<div className='mx-auto max-w-7xl px-6 py-20 lg:px-10'>
<div className='max-w-2xl'>
<p className='text-sm uppercase tracking-[0.3em] text-cyan-200/70'>Programming pillars</p>
<h2 className='mt-4 text-3xl font-semibold text-white sm:text-4xl'>Content lanes designed for a broadcaster, not just a database.</h2>
<p className='mt-4 text-base leading-7 text-slate-300'>
The app now reads like a media brand. Viewers understand the programming mix immediately, while the admin side stays focused on publishing and organization.
</p>
</div>
<div className='mt-10 grid gap-5 md:grid-cols-2 xl:grid-cols-4'>
{contentPillars.map((pillar) => (
<article key={pillar.title} className='rounded-[28px] border border-white/10 bg-white/5 p-6 transition duration-300 hover:-translate-y-1 hover:border-cyan-300/35 hover:bg-white/[0.08]'>
<div className='mb-5 h-12 w-12 rounded-2xl bg-gradient-to-br from-cyan-400/80 to-fuchsia-500/80' />
<h3 className='text-xl font-semibold text-white'>{pillar.title}</h3>
<p className='mt-3 text-sm leading-6 text-slate-300'>{pillar.text}</p>
</article>
))}
</div>
</div>
</section>
<section id='workflow' className='border-t border-white/5 bg-[#070d18]'>
<div className='mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[0.9fr_1.1fr] lg:px-10'>
<div>
<p className='text-sm uppercase tracking-[0.3em] text-cyan-200/70'>Workflow</p>
<h2 className='mt-4 text-3xl font-semibold text-white sm:text-4xl'>A thin but complete first slice for editorial teams.</h2>
<p className='mt-4 text-base leading-7 text-slate-300'>
Editors start on a branded landing page, sign in, open the media control room, search or filter the library, preview live content, and jump into the existing CRUD forms to create or manage records.
</p>
<ul className='mt-8 space-y-3'>
{portalHighlights.map((highlight) => (
<li key={highlight} className='flex items-start gap-3 text-sm leading-6 text-slate-200'>
<span className='mt-2 h-2 w-2 rounded-full bg-cyan-300' />
<span>{highlight}</span>
</li>
))}
</ul>
</div>
<div className='grid gap-5'>
{workflowSteps.map((item) => (
<article key={item.step} className='rounded-[28px] border border-white/10 bg-white/5 p-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>{item.step}</p>
<h3 className='mt-2 text-2xl font-semibold text-white'>{item.title}</h3>
</div>
<div className='rounded-full border border-white/10 px-4 py-2 text-sm text-slate-200'>Aliyo Momot workflow</div>
</div>
<p className='mt-4 max-w-2xl text-sm leading-7 text-slate-300'>{item.text}</p>
</article>
))}
</div>
</div>
</section>
<section id='portal' className='border-t border-white/5 bg-[#050912]'>
<div className='mx-auto max-w-7xl px-6 py-20 lg:px-10'>
<div className='rounded-[36px] border border-white/10 bg-[linear-gradient(135deg,_rgba(8,17,34,0.96),_rgba(15,23,42,0.92),_rgba(30,41,59,0.9))] p-8 shadow-2xl shadow-cyan-950/25 lg:p-10'>
<div className='grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end'>
<div>
<p className='text-sm uppercase tracking-[0.3em] text-cyan-200/70'>Go from launch to management</p>
<h2 className='mt-4 text-3xl font-semibold text-white sm:text-4xl'>Public brand outside. Admin power inside.</h2>
<p className='mt-4 max-w-3xl text-base leading-7 text-slate-300'>
The portal now has a polished front door plus a direct route into the admin and content workspace. It is intentionally slim, but it already supports the first real broadcast workflow.
</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href={liveRoomHref} color='info' label='Open live room' />
<BaseButton href='/watch' color='whiteDark' outline label='Open public watch hub' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/login' color='whiteDark' outline label='Login' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/dashboard' color='whiteDark' outline label='Open admin interface' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
</div>
</div>
</div>
</section>
</main>
{isNowPlayingVisible && activeTickerStream ? (
<PublicNowPlayingDock
stream={activeTickerStream}
isMinimized={isNowPlayingMinimized}
liveRoomHref={liveRoomHref}
onToggleMinimized={() => setIsNowPlayingMinimized((current) => !current)}
onClose={() => {
setHasDismissedNowPlaying(true);
setIsNowPlayingVisible(false);
}}
/>
) : null}
<style jsx>{`
.live-ticker-track {
animation: liveTicker 34s linear infinite;
width: max-content;
}
.live-ticker-wrap:hover .live-ticker-track {
animation-play-state: paused;
}
@keyframes liveTicker {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
`}</style>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,731 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import axios from 'axios';
import {
mdiAccessPoint,
mdiBroadcast,
mdiMagnify,
mdiMovieOpenPlay,
mdiPlayCircleOutline,
mdiRadio,
mdiTelevisionClassic,
mdiViewGridPlus,
} from '@mdi/js';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import LoadingSpinner from '../components/LoadingSpinner';
import MediaCenterUploadWidget from '../components/MediaCenterUploadWidget';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import { useAppSelector } from '../stores/hooks';
type FilterKind = 'all' | 'show' | 'episode' | 'live';
type LibraryItem = {
id: string;
kind: FilterKind;
title: string;
description: string;
badge: string;
status: string;
categoryName: string;
categoryId: string;
dateLabel: string;
meta: string;
href: string;
isFeatured: boolean;
sortDate: number;
};
const filterTabs: Array<{ label: string; value: FilterKind }> = [
{ label: 'All content', value: 'all' },
{ label: 'Shows', value: 'show' },
{ label: 'Episodes', value: 'episode' },
{ label: 'Live', value: 'live' },
];
const statusStyles: Record<string, string> = {
live: 'bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/20',
scheduled: 'bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/20',
published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/20',
draft: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20',
offline: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20',
archived: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20',
};
const cardAccent: Record<FilterKind, string> = {
all: 'from-sky-500/25 to-violet-500/20',
show: 'from-sky-500/25 to-violet-500/20',
episode: 'from-fuchsia-500/25 to-cyan-500/20',
live: 'from-rose-500/25 to-orange-500/20',
};
function getStatusClass(status?: string) {
return statusStyles[(status || '').toLowerCase()] || 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20';
}
function formatDateLabel(value?: string | Date | null) {
if (!value) return 'No schedule set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'No schedule set';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
function getTimestamp(value?: string | Date | null) {
if (!value) return 0;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 0;
return date.getTime();
}
function toYouTubeEmbed(url?: string) {
if (!url) return '';
try {
const parsed = new URL(url);
if (parsed.hostname.includes('youtu.be')) {
const id = parsed.pathname.replace('/', '');
return id ? `https://www.youtube.com/embed/${id}` : '';
}
if (parsed.hostname.includes('youtube.com')) {
if (parsed.pathname.includes('/embed/')) return url;
const id = parsed.searchParams.get('v');
return id ? `https://www.youtube.com/embed/${id}` : '';
}
} catch (error) {
return '';
}
return '';
}
function isDirectMedia(url?: string, type?: string) {
if (!url) return false;
const lowered = url.toLowerCase();
if (type === 'audio') {
return lowered.endsWith('.mp3') || lowered.endsWith('.aac') || lowered.endsWith('.wav') || lowered.endsWith('.ogg');
}
return (
lowered.endsWith('.mp4') ||
lowered.endsWith('.webm') ||
lowered.endsWith('.mov') ||
lowered.includes('.m3u8')
);
}
const MediaCenterPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [shows, setShows] = useState<any[]>([]);
const [episodes, setEpisodes] = useState<any[]>([]);
const [liveStreams, setLiveStreams] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedKind, setSelectedKind] = useState<FilterKind>('all');
const [selectedCategory, setSelectedCategory] = useState('all');
const canReadShows = currentUser && hasPermission(currentUser, 'READ_SHOWS');
const canReadEpisodes = currentUser && hasPermission(currentUser, 'READ_EPISODES');
const canReadLiveStreams = currentUser && hasPermission(currentUser, 'READ_LIVE_STREAMS');
const canReadCategories = currentUser && hasPermission(currentUser, 'READ_CATEGORIES');
const canCreateShows = currentUser && hasPermission(currentUser, 'CREATE_SHOWS');
const canCreateEpisodes = currentUser && hasPermission(currentUser, 'CREATE_EPISODES');
const canCreateLiveStreams = currentUser && hasPermission(currentUser, 'CREATE_LIVE_STREAMS');
useEffect(() => {
if (!currentUser) return;
let isActive = true;
const loadContent = async () => {
setLoading(true);
setErrorMessage('');
try {
const requests: Promise<{ key: string; rows: any[] }>[] = [];
if (canReadCategories) {
requests.push(
axios.get('categories?page=0&limit=50').then((response) => ({
key: 'categories',
rows: response.data.rows || [],
})),
);
}
if (canReadShows) {
requests.push(
axios.get('shows?page=0&limit=24').then((response) => ({
key: 'shows',
rows: response.data.rows || [],
})),
);
}
if (canReadEpisodes) {
requests.push(
axios.get('episodes?page=0&limit=36').then((response) => ({
key: 'episodes',
rows: response.data.rows || [],
})),
);
}
if (canReadLiveStreams) {
requests.push(
axios.get('live_streams?page=0&limit=18').then((response) => ({
key: 'liveStreams',
rows: response.data.rows || [],
})),
);
}
const results = await Promise.all(requests);
if (!isActive) return;
const mapped = results.reduce<Record<string, any[]>>((accumulator, item) => {
accumulator[item.key] = item.rows;
return accumulator;
}, {});
setCategories(mapped.categories || []);
setShows(mapped.shows || []);
setEpisodes(mapped.episodes || []);
setLiveStreams(mapped.liveStreams || []);
} catch (error) {
console.error('Failed to load media center data:', error);
if (isActive) {
setErrorMessage('We could not load the media library right now. Try refreshing the page in a moment.');
}
} finally {
if (isActive) {
setLoading(false);
}
}
};
loadContent();
return () => {
isActive = false;
};
}, [canReadCategories, canReadEpisodes, canReadLiveStreams, canReadShows, currentUser]);
const featuredLive = useMemo(() => {
if (!liveStreams.length) return null;
return (
liveStreams.find((item) => item.status === 'live') ||
liveStreams.find((item) => item.is_featured) ||
liveStreams[0]
);
}, [liveStreams]);
const libraryItems = useMemo<LibraryItem[]>(() => {
const showItems: LibraryItem[] = shows.map((item) => ({
id: item.id,
kind: 'show',
title: item.title || 'Untitled show',
description: item.summary || 'No summary yet for this show.',
badge: item.show_type ? String(item.show_type).replace(/_/g, ' ') : 'Show',
status: item.status || 'draft',
categoryName: item.category?.name || 'Uncategorized',
categoryId: item.category?.id || '',
dateLabel: item.release_year ? `Released in ${item.release_year}` : 'Release year not set',
meta: item.owner?.firstName ? `Owner: ${item.owner.firstName}` : 'Program shell',
href: `/shows/shows-view/?id=${item.id}`,
isFeatured: Boolean(item.is_featured),
sortDate: item.release_year ? Number(item.release_year) : 0,
}));
const episodeItems: LibraryItem[] = episodes.map((item) => ({
id: item.id,
kind: 'episode',
title: item.title || 'Untitled episode',
description: item.description || 'No description yet for this episode.',
badge:
item.season_number && item.episode_number
? `S${item.season_number} • E${item.episode_number}`
: 'Episode',
status: item.status || 'draft',
categoryName: item.show?.title || 'Standalone episode',
categoryId: '',
dateLabel: formatDateLabel(item.published_at || item.scheduled_at),
meta: item.duration_seconds ? `${Math.round(item.duration_seconds / 60)} min runtime` : 'Runtime not set',
href: `/episodes/episodes-view/?id=${item.id}`,
isFeatured: Boolean(item.is_featured),
sortDate: getTimestamp(item.published_at || item.scheduled_at),
}));
const liveItems: LibraryItem[] = liveStreams.map((item) => ({
id: item.id,
kind: 'live',
title: item.title || 'Untitled stream',
description: item.description || 'No stream description yet.',
badge: item.stream_type === 'audio' ? 'Audio stream' : 'Video stream',
status: item.status || 'offline',
categoryName: item.category?.name || 'Live programming',
categoryId: item.category?.id || '',
dateLabel: formatDateLabel(item.starts_at),
meta: item.host?.firstName ? `Host: ${item.host.firstName}` : 'Host not assigned',
href: `/live_streams/live_streams-view/?id=${item.id}`,
isFeatured: Boolean(item.is_featured),
sortDate: getTimestamp(item.starts_at || item.ends_at),
}));
const allItems = [...showItems, ...episodeItems, ...liveItems];
const query = searchTerm.trim().toLowerCase();
return allItems
.filter((item) => {
const matchesKind = selectedKind === 'all' || item.kind === selectedKind;
const matchesCategory = selectedCategory === 'all' || item.categoryId === selectedCategory;
const haystack = [item.title, item.description, item.categoryName, item.badge, item.meta]
.join(' ')
.toLowerCase();
const matchesQuery = !query || haystack.includes(query);
return matchesKind && matchesCategory && matchesQuery;
})
.sort((first, second) => {
if (first.isFeatured !== second.isFeatured) return Number(second.isFeatured) - Number(first.isFeatured);
return second.sortDate - first.sortDate;
});
}, [episodes, liveStreams, searchTerm, selectedCategory, selectedKind, shows]);
const upcomingLive = useMemo(() => {
return [...liveStreams]
.filter((item) => item.status === 'live' || item.status === 'scheduled')
.sort((first, second) => getTimestamp(first.starts_at) - getTimestamp(second.starts_at))
.slice(0, 4);
}, [liveStreams]);
const recentEpisodes = useMemo(() => {
return [...episodes]
.sort((first, second) => getTimestamp(second.published_at || second.scheduled_at) - getTimestamp(first.published_at || first.scheduled_at))
.slice(0, 4);
}, [episodes]);
const totals = {
shows: shows.length,
episodes: episodes.length,
liveStreams: liveStreams.length,
featured: [...shows, ...episodes, ...liveStreams].filter((item) => Boolean(item.is_featured)).length,
};
const playerEmbed = toYouTubeEmbed(featuredLive?.stream_url);
const canUseDirectPlayer = isDirectMedia(featuredLive?.stream_url, featuredLive?.stream_type);
return (
<>
<Head>
<title>{getPageTitle('Media center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiBroadcast} title='Media center' main>
<div className='flex flex-wrap gap-2'>
{canCreateShows && <BaseButton href='/shows/shows-new' color='info' label='New show' />}
{canCreateEpisodes && <BaseButton href='/episodes/episodes-new' color='info' outline label='New episode' />}
{canCreateLiveStreams && <BaseButton href='/live_streams/live_streams-new' color='info' outline label='New live stream' />}
</div>
</SectionTitleLineWithButton>
<CardBox className='mb-6 overflow-hidden border-0 bg-transparent shadow-none'>
<div className='grid gap-6 rounded-[30px] bg-[linear-gradient(135deg,_#082032,_#0f172a_50%,_#1e293b)] p-6 text-white lg:grid-cols-[1.1fr_0.9fr] lg:p-8'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-cyan-300/20 bg-cyan-300/10 px-4 py-2 text-sm text-cyan-100'>
<span className='h-2 w-2 rounded-full bg-emerald-400' />
Aliyo Momot editorial workflow
</div>
<h2 className='mt-4 text-3xl font-semibold tracking-tight'>Operate the broadcast from one workspace.</h2>
<p className='mt-4 max-w-2xl text-sm leading-7 text-slate-200/90'>
Browse live streams, published episodes, and show shells without hopping across multiple CRUD screens. When you need to edit or create content, jump directly into the existing admin forms.
</p>
<div className='mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Shows</p>
<p className='mt-3 text-3xl font-semibold'>{totals.shows}</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Episodes</p>
<p className='mt-3 text-3xl font-semibold'>{totals.episodes}</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Live streams</p>
<p className='mt-3 text-3xl font-semibold'>{totals.liveStreams}</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Featured items</p>
<p className='mt-3 text-3xl font-semibold'>{totals.featured}</p>
</div>
</div>
</div>
<div className='rounded-[28px] border border-white/10 bg-slate-950/30 p-5'>
<div className='flex items-center justify-between gap-4'>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-cyan-100/70'>Next action</p>
<h3 className='mt-2 text-xl font-semibold'>Programming checklist</h3>
</div>
<BaseIcon path={mdiViewGridPlus} size={34} className='text-cyan-200' />
</div>
<div className='mt-6 space-y-3 text-sm text-slate-200'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>Review the featured live stream and make sure the link is playable.</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>Search the library below, then open details pages for final polish.</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>Use the quick actions to create the next show, episode, or stream.</div>
</div>
</div>
</div>
</CardBox>
<MediaCenterUploadWidget className='mb-6' />
{loading && <LoadingSpinner />}
{!loading && errorMessage && (
<CardBox className='mb-6 border border-red-200 bg-red-50 dark:border-red-900/40 dark:bg-red-950/20'>
<div className='space-y-3'>
<p className='text-lg font-semibold text-red-700 dark:text-red-300'>Unable to load the media center</p>
<p className='text-sm text-red-600 dark:text-red-200'>{errorMessage}</p>
</div>
</CardBox>
)}
{!loading && !errorMessage && (
<>
<div className='mb-6 grid gap-6 xl:grid-cols-[1.35fr_0.65fr]'>
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
<div className='flex flex-col gap-5'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Discovery tools</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Search the catalog</h3>
</div>
<div className='flex flex-wrap gap-2'>
{filterTabs.map((tab) => {
const active = selectedKind === tab.value;
return (
<button
key={tab.value}
type='button'
onClick={() => setSelectedKind(tab.value)}
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
active
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-950'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700'
}`}
>
{tab.label}
</button>
);
})}
</div>
</div>
<div className='grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]'>
<label className='flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400'>
<BaseIcon path={mdiMagnify} size={22} />
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder='Search by title, show, category, or status'
className='w-full bg-transparent text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-white'
/>
</label>
<label className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'>
<span className='mb-2 block text-xs uppercase tracking-[0.22em]'>Category</span>
<select
value={selectedCategory}
onChange={(event) => setSelectedCategory(event.target.value)}
className='w-full bg-transparent text-sm text-slate-900 outline-none dark:text-white'
>
<option value='all'>All categories</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name || 'Unnamed category'}
</option>
))}
</select>
</label>
</div>
<p className='text-sm text-slate-500 dark:text-slate-300'>
{libraryItems.length} result{libraryItems.length === 1 ? '' : 's'} found. Use the cards below to open detail pages or jump into list screens.
</p>
</div>
</CardBox>
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
<div className='space-y-4'>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Quick navigation</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Admin shortcuts</h3>
</div>
<div className='grid gap-3'>
{canReadShows && <BaseButton href='/shows/shows-list' color='info' label='Open shows library' />}
{canReadEpisodes && <BaseButton href='/episodes/episodes-list' color='info' outline label='Open episodes library' />}
{canReadLiveStreams && <BaseButton href='/live_streams/live_streams-list' color='info' outline label='Open live streams' />}
</div>
</div>
</CardBox>
</div>
<div className='mb-6 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
<CardBox className='overflow-hidden border border-slate-200/70 bg-white dark:border-slate-800'>
<div className='flex flex-col gap-5'>
<div className='flex items-center justify-between gap-4'>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Featured live booth</p>
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Preview the current stream</h3>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${getStatusClass(featuredLive?.status)}`}>
{featuredLive?.status || 'No live stream'}
</span>
</div>
{featuredLive ? (
<>
<div className='rounded-[26px] bg-[linear-gradient(135deg,_rgba(14,165,233,0.14),_rgba(15,23,42,0.06),_rgba(168,85,247,0.12))] p-4 dark:bg-[linear-gradient(135deg,_rgba(14,165,233,0.16),_rgba(2,6,23,0.95),_rgba(168,85,247,0.18))]'>
{playerEmbed ? (
<div className='aspect-video overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950 dark:border-slate-800'>
<iframe
src={playerEmbed}
title={featuredLive.title}
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
allowFullScreen
className='h-full w-full'
/>
</div>
) : featuredLive.stream_type === 'audio' && canUseDirectPlayer ? (
<div className='rounded-[22px] border border-slate-200 bg-slate-950 p-5 dark:border-slate-800'>
<div className='flex items-center gap-3 text-white'>
<BaseIcon path={mdiRadio} size={28} className='text-cyan-300' />
<div>
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Audio preview</p>
<p className='text-lg font-semibold text-white'>{featuredLive.title}</p>
</div>
</div>
<audio controls className='mt-5 w-full'>
<source src={featuredLive.stream_url} />
Your browser does not support audio playback.
</audio>
</div>
) : featuredLive.stream_type === 'video' && canUseDirectPlayer ? (
<div className='aspect-video overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950 dark:border-slate-800'>
<video controls className='h-full w-full bg-black'>
<source src={featuredLive.stream_url} />
Your browser does not support video playback.
</video>
</div>
) : (
<div className='flex aspect-video flex-col items-center justify-center gap-4 rounded-[22px] border border-dashed border-slate-300 bg-slate-50 text-center dark:border-slate-700 dark:bg-slate-900/70'>
<BaseIcon path={mdiPlayCircleOutline} size={50} className='text-cyan-500' />
<div className='max-w-md space-y-2 px-5'>
<p className='text-lg font-semibold text-slate-900 dark:text-white'>Preview not embedded yet</p>
<p className='text-sm leading-6 text-slate-500 dark:text-slate-300'>
This stream uses a custom URL. Open the stream directly or update the link to a playable media source or YouTube embed URL.
</p>
</div>
{featuredLive.stream_url && (
<a
href={featuredLive.stream_url}
target='_blank'
rel='noreferrer'
className='rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200'
>
Open stream URL
</a>
)}
</div>
)}
</div>
<div className='grid gap-4 md:grid-cols-[1fr_auto] md:items-end'>
<div>
<h4 className='text-xl font-semibold text-slate-900 dark:text-white'>{featuredLive.title}</h4>
<p className='mt-2 text-sm leading-7 text-slate-500 dark:text-slate-300'>{featuredLive.description || 'No stream description yet.'}</p>
<div className='mt-4 flex flex-wrap gap-2'>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
{featuredLive.stream_type === 'audio' ? 'Audio stream' : 'Video stream'}
</span>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
{featuredLive.category?.name || 'Live programming'}
</span>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
{formatDateLabel(featuredLive.starts_at)}
</span>
</div>
</div>
<BaseButton href={`/live_streams/live_streams-view/?id=${featuredLive.id}`} color='info' label='Open stream details' />
</div>
</>
) : (
<div className='rounded-[24px] border border-dashed border-slate-300 bg-slate-50 p-6 text-center dark:border-slate-700 dark:bg-slate-900/70'>
<p className='text-lg font-semibold text-slate-900 dark:text-white'>No live streams yet</p>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>Create a live stream to turn this area into an operational preview booth.</p>
{canCreateLiveStreams && <BaseButton href='/live_streams/live_streams-new' color='info' label='Create live stream' className='mt-4' />}
</div>
)}
</div>
</CardBox>
<div className='grid gap-6'>
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
<div>
<div className='flex items-center gap-3'>
<BaseIcon path={mdiAccessPoint} size={24} className='text-cyan-500' />
<h3 className='text-xl font-semibold text-slate-900 dark:text-white'>Upcoming live slots</h3>
</div>
<div className='mt-5 space-y-3'>
{upcomingLive.length ? (
upcomingLive.map((item) => (
<a
key={item.id}
href={`/live_streams/live_streams-view/?id=${item.id}`}
className='block rounded-2xl border border-slate-200 p-4 transition hover:border-cyan-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-900/70'
>
<div className='flex items-center justify-between gap-3'>
<p className='font-semibold text-slate-900 dark:text-white'>{item.title || 'Untitled stream'}</p>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${getStatusClass(item.status)}`}>{item.status || 'offline'}</span>
</div>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>{formatDateLabel(item.starts_at)}</p>
</a>
))
) : (
<p className='text-sm text-slate-500 dark:text-slate-300'>No live schedule yet.</p>
)}
</div>
</div>
</CardBox>
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
<div>
<div className='flex items-center gap-3'>
<BaseIcon path={mdiMovieOpenPlay} size={24} className='text-fuchsia-500' />
<h3 className='text-xl font-semibold text-slate-900 dark:text-white'>Recent episodes</h3>
</div>
<div className='mt-5 space-y-3'>
{recentEpisodes.length ? (
recentEpisodes.map((item) => (
<a
key={item.id}
href={`/episodes/episodes-view/?id=${item.id}`}
className='block rounded-2xl border border-slate-200 p-4 transition hover:border-fuchsia-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-900/70'
>
<p className='font-semibold text-slate-900 dark:text-white'>{item.title || 'Untitled episode'}</p>
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>{formatDateLabel(item.published_at || item.scheduled_at)}</p>
</a>
))
) : (
<p className='text-sm text-slate-500 dark:text-slate-300'>No episodes published yet.</p>
)}
</div>
</div>
</CardBox>
</div>
</div>
{libraryItems.length ? (
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
{libraryItems.map((item) => (
<CardBox
key={`${item.kind}-${item.id}`}
className='border border-slate-200/70 bg-white transition duration-300 hover:-translate-y-1 hover:shadow-xl dark:border-slate-800'
isHoverable
>
<div className='flex h-full flex-col'>
<div className={`rounded-[24px] bg-gradient-to-br ${cardAccent[item.kind]} p-4 dark:from-slate-900/90 dark:to-slate-800`}>
<div className='flex items-start justify-between gap-3'>
<div>
<span className='rounded-full bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-slate-700 dark:bg-white/10 dark:text-slate-200'>
{item.badge}
</span>
<h3 className='mt-4 text-xl font-semibold text-slate-900 dark:text-white'>{item.title}</h3>
</div>
<BaseIcon
path={item.kind === 'show' ? mdiTelevisionClassic : item.kind === 'episode' ? mdiMovieOpenPlay : mdiBroadcast}
size={30}
className='text-slate-700 dark:text-slate-100'
/>
</div>
</div>
<div className='flex flex-1 flex-col justify-between pt-5'>
<div>
<div className='flex flex-wrap gap-2'>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${getStatusClass(item.status)}`}>{item.status}</span>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
{item.categoryName}
</span>
</div>
<p className='mt-4 text-sm leading-7 text-slate-500 dark:text-slate-300'>{item.description}</p>
</div>
<div className='mt-6 space-y-4'>
<div className='space-y-2 text-sm text-slate-500 dark:text-slate-300'>
<p>{item.dateLabel}</p>
<p>{item.meta}</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href={item.href} color='info' label='View details' />
{item.kind === 'show' && canReadShows && <BaseButton href='/shows/shows-list' color='whiteDark' outline label='Open list' />}
{item.kind === 'episode' && canReadEpisodes && <BaseButton href='/episodes/episodes-list' color='whiteDark' outline label='Open list' />}
{item.kind === 'live' && canReadLiveStreams && <BaseButton href='/live_streams/live_streams-list' color='whiteDark' outline label='Open list' />}
</div>
</div>
</div>
</div>
</CardBox>
))}
</div>
) : (
<CardBox className='border border-dashed border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/60'>
<div className='rounded-[24px] p-6 text-center'>
<p className='text-2xl font-semibold text-slate-900 dark:text-white'>No matching content yet</p>
<p className='mx-auto mt-3 max-w-2xl text-sm leading-7 text-slate-500 dark:text-slate-300'>
Adjust the search and filters, or create the first show, episode, or live stream to start shaping the Aliyo Momot media catalog.
</p>
<div className='mt-6 flex flex-wrap justify-center gap-3'>
{canCreateShows && <BaseButton href='/shows/shows-new' color='info' label='Create show' />}
{canCreateEpisodes && <BaseButton href='/episodes/episodes-new' color='info' outline label='Create episode' />}
{canCreateLiveStreams && <BaseButton href='/live_streams/live_streams-new' color='info' outline label='Create live stream' />}
</div>
</div>
</CardBox>
)}
</>
)}
</SectionMain>
</>
);
};
MediaCenterPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default MediaCenterPage;

View File

@ -0,0 +1,216 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mdiArrowLeft } from '@mdi/js';
import BaseIcon from '../../../components/BaseIcon';
import LoadingSpinner from '../../../components/LoadingSpinner';
import PublicMediaPlayer from '../../../components/PublicMediaPlayer';
import { getPageTitle } from '../../../config';
import {
formatDuration,
formatMediaDate,
getPrimaryPlaybackType,
getPrimaryPlaybackUrls,
humanizeMediaKind,
} from '../../../helpers/publicMedia';
import LayoutGuest from '../../../layouts/Guest';
export default function PublicEpisodeDetailsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [episode, setEpisode] = useState<any>(null);
useEffect(() => {
if (!router.isReady || typeof router.query.id !== 'string') return;
let isMounted = true;
const loadEpisode = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get(`/public-media/episodes/${router.query.id}`);
if (isMounted) {
setEpisode(response.data);
}
} catch (error) {
console.error('Failed to load public episode:', error);
if (isMounted) {
setErrorMessage('We could not load this episode right now.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadEpisode();
return () => {
isMounted = false;
};
}, [router.isReady, router.query.id]);
const playback = useMemo(() => getPrimaryPlaybackUrls(episode), [episode]);
const playbackType = getPrimaryPlaybackType(episode);
const alternateAssets = useMemo(() => {
const assets = Array.isArray(episode?.media_assets) ? episode.media_assets : [];
return assets.filter((item) => item.id !== episode?.primaryMediaAsset?.id);
}, [episode]);
return (
<>
<Head>
<title>{getPageTitle(episode?.title || 'Episode')}</title>
<meta name='description' content={episode?.description || 'Public episode detail page for Aliyo Momot.'} />
</Head>
<main className='min-h-screen bg-[#050816] text-white'>
{loading ? (
<LoadingSpinner />
) : errorMessage ? (
<div className='mx-auto max-w-4xl px-6 py-16 lg:px-10'>
<div className='rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-100'>{errorMessage}</div>
</div>
) : !episode ? (
<div className='mx-auto max-w-4xl px-6 py-16 text-slate-300 lg:px-10'>Episode not found.</div>
) : (
<>
<section className='border-b border-white/5 bg-[linear-gradient(135deg,_#050816_0%,_#0f172a_55%,_#111827_100%)]'>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<Link href={episode.show ? `/watch/shows/${episode.show.id}` : '/watch/episodes'} className='inline-flex items-center gap-2 text-sm text-cyan-200 transition hover:text-white'>
<BaseIcon path={mdiArrowLeft} size={16} />
{episode.show ? `Back to ${episode.show.title}` : 'Back to episodes'}
</Link>
<div className='mt-8 grid gap-8 lg:grid-cols-[1.15fr_0.85fr]'>
<div>
<div className='flex flex-wrap gap-3'>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.22em] text-cyan-100'>
{episode.show?.category?.name || 'Episode'}
</div>
{episode.primaryMediaAsset?.asset_type && (
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.22em] text-slate-300'>
{humanizeMediaKind(episode.primaryMediaAsset.asset_type)}
</div>
)}
</div>
<h1 className='mt-5 text-4xl font-semibold text-white sm:text-5xl'>{episode.title}</h1>
<p className='mt-5 max-w-3xl text-base leading-8 text-slate-300'>{episode.description || 'Public playback detail page for this published episode.'}</p>
<div className='mt-8 flex flex-wrap gap-3 text-sm text-slate-300'>
{episode.show?.title && <div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{episode.show.title}</div>}
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{formatMediaDate(episode.published_at, false)}</div>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{formatDuration(episode.duration_seconds)}</div>
</div>
</div>
<div className='rounded-[28px] border border-white/10 bg-white/5 p-5'>
<PublicMediaPlayer
title={episode.title}
url={playback.primaryUrl}
fallbackUrl={playback.fallbackUrl}
type={playbackType}
posterUrl={episode.thumbnailImageUrl}
externalMessage='This episode uses an external or custom playback URL. Open it in a new tab.'
externalLabel='Open media source'
emptyMessage='No public playback source is attached to this episode yet.'
/>
</div>
</div>
</div>
</section>
<section>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<div className='grid gap-8 lg:grid-cols-[1fr_320px]'>
<div className='rounded-[28px] border border-white/10 bg-white/5 p-6'>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Media sources</p>
<h2 className='mt-3 text-2xl font-semibold text-white'>Available assets</h2>
{episode.primaryMediaAsset ? (
<div className='mt-6 rounded-[22px] border border-white/10 bg-slate-950/60 p-5'>
<p className='text-xs uppercase tracking-[0.24em] text-emerald-200'>Primary asset</p>
<h3 className='mt-3 text-xl font-semibold text-white'>{episode.primaryMediaAsset.title || 'Primary playback source'}</h3>
<div className='mt-4 flex flex-wrap gap-2 text-sm text-slate-300'>
<span>{humanizeMediaKind(episode.primaryMediaAsset.asset_type)}</span>
{episode.primaryMediaAsset.resolution && (
<>
<span className='text-slate-500'></span>
<span>{episode.primaryMediaAsset.resolution}</span>
</>
)}
</div>
</div>
) : null}
{alternateAssets.length ? (
<div className='mt-5 grid gap-4'>
{alternateAssets.map((asset) => (
<div key={asset.id} className='rounded-[22px] border border-white/10 bg-slate-950/40 p-5'>
<h3 className='text-lg font-semibold text-white'>{asset.title || 'Additional asset'}</h3>
<div className='mt-3 flex flex-wrap gap-2 text-sm text-slate-300'>
<span>{humanizeMediaKind(asset.asset_type)}</span>
{asset.resolution && (
<>
<span className='text-slate-500'></span>
<span>{asset.resolution}</span>
</>
)}
</div>
{(asset.fileUrl || asset.source_url) && (
<a
href={asset.fileUrl || asset.source_url}
target='_blank'
rel='noreferrer'
className='mt-4 inline-flex rounded-full border border-white/10 px-4 py-2 text-sm text-cyan-200 transition hover:border-cyan-300/40 hover:text-white'
>
Open asset
</a>
)}
</div>
))}
</div>
) : null}
</div>
<aside className='rounded-[28px] border border-white/10 bg-white/5 p-6'>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Episode info</p>
<div className='mt-6 space-y-4 text-sm text-slate-300'>
<div>
<p className='text-slate-500'>Published</p>
<p className='mt-1 text-white'>{formatMediaDate(episode.published_at)}</p>
</div>
<div>
<p className='text-slate-500'>Duration</p>
<p className='mt-1 text-white'>{formatDuration(episode.duration_seconds)}</p>
</div>
{episode.show?.title && (
<div>
<p className='text-slate-500'>Show</p>
<Link href={`/watch/shows/${episode.show.id}`} className='mt-1 inline-block text-cyan-200 transition hover:text-white'>
{episode.show.title}
</Link>
</div>
)}
</div>
</aside>
</div>
</div>
</section>
</>
)}
</main>
</>
);
}
PublicEpisodeDetailsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,214 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mdiMagnify, mdiMovieOpenPlay } from '@mdi/js';
import BaseIcon from '../../../components/BaseIcon';
import LoadingSpinner from '../../../components/LoadingSpinner';
import { getPageTitle } from '../../../config';
import { formatDuration, formatMediaDate, getPrimaryPlaybackUrl } from '../../../helpers/publicMedia';
import LayoutGuest from '../../../layouts/Guest';
export default function PublicEpisodesPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [episodes, setEpisodes] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
useEffect(() => {
if (!router.isReady) return;
const categoryFromQuery = typeof router.query.categoryId === 'string' ? router.query.categoryId : '';
const queryFromUrl = typeof router.query.q === 'string' ? router.query.q : '';
setSelectedCategory(categoryFromQuery);
setSearchTerm(queryFromUrl);
}, [router.isReady, router.query.categoryId, router.query.q]);
useEffect(() => {
let isMounted = true;
const loadCategories = async () => {
try {
const response = await axios.get('/public-media/categories', { params: { limit: 24 } });
if (isMounted) {
setCategories(response.data?.rows || []);
}
} catch (error) {
console.error('Failed to load public categories:', error);
}
};
loadCategories();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadEpisodes = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get('/public-media/episodes', {
params: {
limit: 24,
q: searchTerm || undefined,
categoryId: selectedCategory || undefined,
},
});
if (isMounted) {
setEpisodes(response.data?.rows || []);
}
} catch (error) {
console.error('Failed to load public episodes:', error);
if (isMounted) {
setErrorMessage('We could not load episodes right now.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadEpisodes();
return () => {
isMounted = false;
};
}, [searchTerm, selectedCategory]);
const applyCategory = (categoryId: string) => {
setSelectedCategory(categoryId);
void router.replace(
{
pathname: '/watch/episodes',
query: {
...(searchTerm ? { q: searchTerm } : {}),
...(categoryId ? { categoryId } : {}),
},
},
undefined,
{ shallow: true },
);
};
return (
<>
<Head>
<title>{getPageTitle('Episodes')}</title>
<meta name='description' content='Browse the public episode archive for Aliyo Momot.' />
</Head>
<main className='min-h-screen bg-[#050816] text-white'>
<section className='border-b border-white/5 bg-[linear-gradient(135deg,_#050816_0%,_#0f172a_55%,_#111827_100%)]'>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<Link href='/watch' className='text-sm text-cyan-200 transition hover:text-white'>
Back to watch hub
</Link>
<div className='mt-6 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Episode library</p>
<h1 className='mt-3 text-4xl font-semibold text-white sm:text-5xl'>Published episodes, ready to play.</h1>
<p className='mt-4 max-w-3xl text-base leading-7 text-slate-300'>
Search by title or description, filter by category, and open any episode page for playback details.
</p>
</div>
</div>
<div className='mt-8 rounded-[28px] border border-white/10 bg-white/5 p-5'>
<label className='flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/60 px-4 py-3'>
<BaseIcon path={mdiMagnify} size={20} className='text-cyan-200' />
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder='Search episode titles or descriptions'
className='w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-500'
/>
</label>
<div className='mt-4 flex flex-wrap gap-3'>
<button
type='button'
onClick={() => applyCategory('')}
className={`rounded-full px-4 py-2 text-sm transition ${selectedCategory ? 'border border-white/10 bg-white/5 text-slate-300 hover:text-white' : 'bg-white text-slate-950'}`}
>
All categories
</button>
{categories.map((category) => (
<button
key={category.id}
type='button'
onClick={() => applyCategory(category.id)}
className={`rounded-full px-4 py-2 text-sm transition ${selectedCategory === category.id ? 'bg-cyan-300 text-slate-950' : 'border border-white/10 bg-white/5 text-slate-300 hover:text-white'}`}
>
{category.name}
</button>
))}
</div>
</div>
</div>
</section>
<section>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
{loading ? (
<LoadingSpinner />
) : errorMessage ? (
<div className='rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-100'>{errorMessage}</div>
) : episodes.length ? (
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
{episodes.map((episode) => {
const playableUrl = getPrimaryPlaybackUrl(episode);
return (
<Link key={episode.id} href={`/watch/episodes/${episode.id}`} className='group rounded-[28px] border border-white/10 bg-white/5 p-6 transition hover:-translate-y-1 hover:border-fuchsia-300/35 hover:bg-white/[0.08]'>
<div className='flex items-start justify-between gap-4'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>
<BaseIcon path={mdiMovieOpenPlay} size={28} className='text-fuchsia-200' />
</div>
<span className='rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300'>
{episode.show?.category?.name || 'Episode'}
</span>
</div>
<h2 className='mt-6 text-2xl font-semibold text-white'>{episode.title}</h2>
<p className='mt-3 line-clamp-3 text-sm leading-6 text-slate-300'>{episode.description || 'Published episode ready for viewing.'}</p>
<div className='mt-6 flex flex-wrap gap-2 text-xs uppercase tracking-[0.18em] text-slate-400'>
<span>{episode.show?.title || 'Standalone episode'}</span>
<span></span>
<span>{formatMediaDate(episode.published_at, false)}</span>
</div>
<div className='mt-4 flex flex-wrap gap-2 text-sm text-slate-300'>
<span>{formatDuration(episode.duration_seconds)}</span>
<span className='text-slate-500'></span>
<span>{playableUrl ? 'Playback available' : 'Detail page available'}</span>
</div>
</Link>
);
})}
</div>
) : (
<div className='rounded-[24px] border border-white/10 bg-white/5 p-8 text-center text-slate-300'>
No published episodes matched your current filters.
</div>
)}
</div>
</section>
</main>
</>
);
}
PublicEpisodesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,307 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import axios from 'axios';
import { mdiBroadcast, mdiMovieOpenPlay, mdiPlayCircleOutline, mdiTelevisionClassic } from '@mdi/js';
import BaseButton from '../../components/BaseButton';
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import PublicMediaPlayer from '../../components/PublicMediaPlayer';
import { getPageTitle } from '../../config';
import {
formatMediaDate,
getLivePlaybackUrls,
getPrimaryPlaybackType,
getPrimaryPlaybackUrl,
getPublicStatusClass,
humanizeMediaKind,
pickPreferredLiveStream,
} from '../../helpers/publicMedia';
import LayoutGuest from '../../layouts/Guest';
const cardBackground = (imageUrl?: string) =>
imageUrl
? {
backgroundImage: `linear-gradient(180deg, rgba(7,11,26,0.05), rgba(7,11,26,0.92)), url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: undefined;
export default function WatchHomePage() {
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [data, setData] = useState<any>({ categories: [], shows: [], episodes: [], liveStreams: [] });
useEffect(() => {
let isMounted = true;
const loadOverview = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get('/public-media/overview');
if (isMounted) {
setData({
categories: response.data?.categories || [],
shows: response.data?.shows || [],
episodes: response.data?.episodes || [],
liveStreams: response.data?.liveStreams || [],
});
}
} catch (error) {
console.error('Failed to load public media overview:', error);
if (isMounted) {
setErrorMessage('We could not load the watch hub right now. Please refresh in a moment.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadOverview();
return () => {
isMounted = false;
};
}, []);
const featuredStream = useMemo(() => {
const items = data.liveStreams || [];
return pickPreferredLiveStream(items);
}, [data.liveStreams]);
const featuredStreamPlayback = useMemo(() => getLivePlaybackUrls(featuredStream), [featuredStream]);
return (
<>
<Head>
<title>{getPageTitle('Watch')}</title>
<meta
name='description'
content='Public watch hub for Aliyo Momot with live streams, featured shows, and the latest published episodes.'
/>
</Head>
<main className='min-h-screen bg-[#050816] text-white'>
<section className='relative overflow-hidden border-b border-white/5'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.20),_transparent_30%),radial-gradient(circle_at_80%_20%,_rgba(168,85,247,0.20),_transparent_25%),linear-gradient(135deg,_#050816_0%,_#0f172a_55%,_#111827_100%)]' />
<div className='relative mx-auto max-w-7xl px-6 py-8 lg:px-10 lg:py-10'>
<div className='flex flex-col gap-5 rounded-[30px] border border-white/10 bg-white/5 p-5 backdrop-blur md:flex-row md:items-center md:justify-between'>
<Link href='/' className='flex items-center gap-3'>
<span className='flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-cyan-400 to-fuchsia-500 text-lg font-semibold text-slate-950'>
AM
</span>
<div>
<p className='text-xs uppercase tracking-[0.35em] text-cyan-200/80'>Aliyo Momot</p>
<p className='text-lg font-semibold text-white'>Watch hub</p>
</div>
</Link>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/watch/live' color='info' label='Watch live' />
<BaseButton href='/watch/shows' color='whiteDark' outline label='Browse shows' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/watch/episodes' color='whiteDark' outline label='Latest episodes' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
</div>
<div className='grid gap-10 py-12 lg:grid-cols-[0.92fr_1.08fr] lg:py-16'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100'>
<span className='h-2 w-2 rounded-full bg-emerald-400' />
Public viewing for live and on-demand programming
</div>
<h1 className='mt-6 text-5xl font-semibold tracking-tight text-white sm:text-6xl'>
Watch Aliyo Momot shows, live streams, and recent episodes.
</h1>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-300'>
This public portal surfaces the broadcast side of the platform: featured programming, active streams,
and easy entry points into each show and episode.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton href='/watch/live' color='info' label='Play live now' />
<BaseButton href='/watch/shows' color='whiteDark' outline label='Explore shows' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/watch/episodes' color='whiteDark' outline label='Open episode library' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
</div>
<div className='rounded-[30px] border border-white/10 bg-slate-950/60 p-5 shadow-2xl shadow-cyan-950/20'>
<div className='flex items-center justify-between gap-4'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Featured live</p>
<h2 className='mt-2 text-2xl font-semibold text-white'>
{featuredStream?.title || 'No live stream featured yet'}
</h2>
</div>
{featuredStream?.status && (
<span className={`rounded-full px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] ${getPublicStatusClass(featuredStream.status)}`}>
{featuredStream.status}
</span>
)}
</div>
<div className='mt-5'>
{loading ? (
<LoadingSpinner />
) : errorMessage ? (
<div className='rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-100'>{errorMessage}</div>
) : featuredStream ? (
<>
<PublicMediaPlayer
title={featuredStream.title}
url={featuredStreamPlayback.primaryUrl}
fallbackUrl={featuredStreamPlayback.fallbackUrl}
type={featuredStream.stream_type}
posterUrl={featuredStream.coverImageUrl}
externalMessage='This stream opens in a separate player or external broadcast window.'
externalLabel='Open stream'
emptyMessage='No live or scheduled streams are published yet.'
/>
<div className='mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-300'>
<span>{featuredStream.category?.name || 'Live program'}</span>
<span className='text-slate-500'></span>
<span>{formatMediaDate(featuredStream.starts_at)}</span>
</div>
<div className='mt-5'>
<BaseButton href='/watch/live' color='whiteDark' outline label='Open dedicated live room' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
</>
) : (
<div className='rounded-[22px] border border-white/10 bg-slate-900/60 p-6 text-sm text-slate-300'>
No live or scheduled streams are published yet.
</div>
)}
</div>
</div>
</div>
</div>
</section>
<section className='border-b border-white/5 bg-[#08101f]'>
<div className='mx-auto max-w-7xl px-6 py-16 lg:px-10'>
<div className='flex items-end justify-between gap-6'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Featured shows</p>
<h2 className='mt-3 text-3xl font-semibold text-white'>Start with the programs behind the brand.</h2>
</div>
<Link href='/watch/shows' className='text-sm text-cyan-200 transition hover:text-white'>
See all shows
</Link>
</div>
<div className='mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
{(data.shows || []).map((show) => (
<Link key={show.id} href={`/watch/shows/${show.id}`} className='group overflow-hidden rounded-[28px] border border-white/10 bg-slate-900/60 p-6 transition hover:-translate-y-1 hover:border-cyan-300/30'>
<div className='rounded-[22px] p-5' style={cardBackground(show.posterImageUrl || show.bannerImageUrl)}>
<div className='min-h-[160px] rounded-[18px] border border-white/10 bg-black/25 p-5 backdrop-blur-sm'>
<div className='inline-flex rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs uppercase tracking-[0.22em] text-cyan-100'>
{humanizeMediaKind(show.show_type)}
</div>
<h3 className='mt-6 text-2xl font-semibold text-white'>{show.title}</h3>
<p className='mt-3 line-clamp-3 text-sm leading-6 text-slate-200'>{show.summary || 'This show is now part of the public watch experience.'}</p>
</div>
</div>
<div className='mt-5 flex items-center justify-between text-sm text-slate-300'>
<span>{show.category?.name || 'Uncategorized'}</span>
<span>{show.release_year || 'Ongoing'}</span>
</div>
</Link>
))}
</div>
</div>
</section>
<section className='border-b border-white/5 bg-[#07111d]'>
<div className='mx-auto max-w-7xl px-6 py-16 lg:px-10'>
<div className='flex items-end justify-between gap-6'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Latest episodes</p>
<h2 className='mt-3 text-3xl font-semibold text-white'>Jump straight into the newest published content.</h2>
</div>
<Link href='/watch/episodes' className='text-sm text-cyan-200 transition hover:text-white'>
Open episodes
</Link>
</div>
<div className='mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-4'>
{(data.episodes || []).map((episode) => {
const playbackUrl = getPrimaryPlaybackUrl(episode);
const playbackType = getPrimaryPlaybackType(episode);
const isPlayable = Boolean(playbackUrl);
return (
<Link key={episode.id} href={`/watch/episodes/${episode.id}`} className='group rounded-[28px] border border-white/10 bg-slate-900/60 p-6 transition hover:-translate-y-1 hover:border-fuchsia-300/30'>
<div className='flex items-start justify-between gap-4'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>
<BaseIcon path={mdiMovieOpenPlay} size={28} className='text-fuchsia-200' />
</div>
<span className={`rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-[0.22em] ${getPublicStatusClass(episode.status)}`}>
{episode.status}
</span>
</div>
<h3 className='mt-5 text-xl font-semibold text-white'>{episode.title}</h3>
<p className='mt-3 line-clamp-3 text-sm leading-6 text-slate-300'>{episode.description || 'Published episode ready for public playback.'}</p>
<div className='mt-5 flex flex-wrap gap-2 text-xs uppercase tracking-[0.18em] text-slate-400'>
<span>{episode.show?.title || 'Standalone episode'}</span>
<span></span>
<span>{formatMediaDate(episode.published_at, false)}</span>
</div>
<div className='mt-5 flex items-center gap-2 text-sm text-cyan-200'>
<BaseIcon path={mdiPlayCircleOutline} size={18} />
{isPlayable ? `${humanizeMediaKind(playbackType)} playback available` : 'View episode details'}
</div>
</Link>
);
})}
</div>
</div>
</section>
<section className='bg-[#050816]'>
<div className='mx-auto max-w-7xl px-6 py-16 lg:px-10'>
<div className='flex items-end justify-between gap-6'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Active categories</p>
<h2 className='mt-3 text-3xl font-semibold text-white'>Browse programming lanes and editorial themes.</h2>
</div>
</div>
<div className='mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
{(data.categories || []).map((category) => (
<Link key={category.id} href={`/watch/shows?categoryId=${category.id}`} className='rounded-[24px] border border-white/10 bg-white/5 p-5 transition hover:border-cyan-300/30 hover:bg-white/[0.08]'>
<div className='inline-flex rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs uppercase tracking-[0.22em] text-cyan-100'>
Category
</div>
<h3 className='mt-4 text-xl font-semibold text-white'>{category.name}</h3>
<p className='mt-3 text-sm leading-6 text-slate-300'>{category.description || 'Open this lane to see the shows published in it.'}</p>
</Link>
))}
</div>
<div className='mt-12 rounded-[32px] border border-white/10 bg-[linear-gradient(135deg,_rgba(8,17,34,0.96),_rgba(15,23,42,0.92),_rgba(30,41,59,0.9))] p-8'>
<div className='flex flex-col gap-5 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Need the control room?</p>
<h3 className='mt-2 text-3xl font-semibold text-white'>Editors can still manage everything from the admin workspace.</h3>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/login' color='whiteDark' outline label='Login' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/media-center' color='info' label='Open media center' icon={mdiTelevisionClassic} />
</div>
</div>
</div>
</div>
</section>
</main>
</>
);
}
WatchHomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,336 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mdiBroadcast, mdiPlayCircleOutline, mdiRadioTower, mdiTelevisionClassic } from '@mdi/js';
import BaseButton from '../../components/BaseButton';
import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import PublicMediaPlayer from '../../components/PublicMediaPlayer';
import { getPageTitle } from '../../config';
import { formatMediaDate, getLivePlaybackUrls, getPublicStatusClass, getPublicStreamQueryId, humanizeMediaKind, resolvePreferredPublicStream } from '../../helpers/publicMedia';
import LayoutGuest from '../../layouts/Guest';
export default function PublicLivePage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [streams, setStreams] = useState<any[]>([]);
const [selectedStreamId, setSelectedStreamId] = useState('');
useEffect(() => {
let isMounted = true;
const loadStreams = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get('/public-media/streams', {
params: {
limit: 12,
includeOfflineDemo: true,
},
});
if (!isMounted) return;
const rows = response.data?.rows || [];
setStreams(rows);
} catch (error) {
console.error('Failed to load public live streams:', error);
if (isMounted) {
setErrorMessage('We could not load the live lineup right now. Please refresh in a moment.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadStreams();
return () => {
isMounted = false;
};
}, []);
const queryStreamId = useMemo(() => getPublicStreamQueryId(router.query.stream), [router.query.stream]);
useEffect(() => {
const preferredStream = resolvePreferredPublicStream(streams, queryStreamId);
if (!preferredStream) {
return;
}
const nextSelectedStreamId = preferredStream.id || '';
if (nextSelectedStreamId !== selectedStreamId) {
setSelectedStreamId(nextSelectedStreamId);
}
}, [queryStreamId, selectedStreamId, streams]);
useEffect(() => {
if (!router.isReady || !selectedStreamId) {
return;
}
const currentQueryStreamId = getPublicStreamQueryId(router.query.stream);
if (currentQueryStreamId === selectedStreamId) {
return;
}
router.replace(
{
pathname: router.pathname,
query: { ...router.query, stream: selectedStreamId },
},
undefined,
{ shallow: true, scroll: false },
);
}, [router, selectedStreamId]);
const selectedStream = useMemo(() => {
return resolvePreferredPublicStream(streams, selectedStreamId) || null;
}, [selectedStreamId, streams]);
const playback = useMemo(() => getLivePlaybackUrls(selectedStream), [selectedStream]);
const liveCount = streams.filter((item) => item.status === 'live').length;
const videoCount = streams.filter((item) => item.stream_type === 'video').length;
const audioCount = streams.filter((item) => item.stream_type === 'audio').length;
return (
<>
<Head>
<title>{getPageTitle('Live')}</title>
<meta
name='description'
content='Listen to live audio streams and watch live video previews from the Aliyo Momot public watch experience.'
/>
</Head>
<main className='min-h-screen bg-[#050816] text-white'>
<section className='border-b border-white/5 bg-[linear-gradient(135deg,_#050816_0%,_#0f172a_50%,_#111827_100%)]'>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<Link href='/watch' className='text-sm text-cyan-200 transition hover:text-white'>
Back to watch hub
</Link>
<div className='mt-6 grid gap-8 lg:grid-cols-[1fr_320px] lg:items-end'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-red-400/25 bg-red-400/10 px-4 py-2 text-sm text-red-100'>
<span className='h-2 w-2 rounded-full bg-red-400' />
Live room for audio and video playback
</div>
<h1 className='mt-6 text-4xl font-semibold tracking-tight text-white sm:text-5xl'>Hear the live audio feed and watch the current video stream.</h1>
<p className='mt-5 max-w-3xl text-base leading-7 text-slate-300'>
This page is tuned for immediate playback. It prioritizes current live streams, keeps audio and video in one lineup,
and falls back to working demo sources whenever a production encoder URL is still a placeholder.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton href='/watch' color='whiteDark' outline label='Open watch hub' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/watch/shows' color='whiteDark' outline label='Browse shows' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/watch/episodes' color='info' label='Latest episodes' />
</div>
</div>
<div className='grid gap-4 sm:grid-cols-3 lg:grid-cols-1'>
<div className='rounded-[24px] border border-white/10 bg-white/5 p-5'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-400'>On air now</p>
<p className='mt-3 text-3xl font-semibold text-white'>{liveCount}</p>
<p className='mt-2 text-sm text-slate-300'>Streams marked live right now.</p>
</div>
<div className='rounded-[24px] border border-white/10 bg-white/5 p-5'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-400'>Audio feeds</p>
<p className='mt-3 text-3xl font-semibold text-white'>{audioCount}</p>
<p className='mt-2 text-sm text-slate-300'>Radio-style listening experiences.</p>
</div>
<div className='rounded-[24px] border border-white/10 bg-white/5 p-5'>
<p className='text-xs uppercase tracking-[0.24em] text-slate-400'>Video feeds</p>
<p className='mt-3 text-3xl font-semibold text-white'>{videoCount}</p>
<p className='mt-2 text-sm text-slate-300'>Watchable streams or demo previews.</p>
</div>
</div>
</div>
</div>
</section>
<section className='border-b border-white/5 bg-[#07111d]'>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<div className='grid gap-8 lg:grid-cols-[1.2fr_0.8fr]'>
<div className='rounded-[30px] border border-white/10 bg-slate-950/60 p-6 shadow-2xl shadow-cyan-950/20'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Now loaded</p>
<h2 className='mt-2 text-3xl font-semibold text-white'>{selectedStream?.title || 'No live stream available yet'}</h2>
</div>
{selectedStream?.status ? (
<span className={`rounded-full px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] ${getPublicStatusClass(selectedStream.status)}`}>
{selectedStream.status}
</span>
) : null}
</div>
<div className='mt-6'>
{loading ? (
<LoadingSpinner />
) : errorMessage ? (
<div className='rounded-[24px] border border-red-500/20 bg-red-500/10 p-5 text-sm text-red-100'>{errorMessage}</div>
) : selectedStream ? (
<>
<PublicMediaPlayer
title={selectedStream.title}
url={playback.primaryUrl}
fallbackUrl={playback.fallbackUrl}
type={selectedStream.stream_type}
posterUrl={selectedStream.coverImageUrl}
externalMessage='This stream uses an external playback URL. Open it in a new tab.'
externalLabel='Open stream source'
emptyMessage='No public streams are available yet.'
/>
<div className='mt-5 flex flex-wrap gap-3 text-sm text-slate-300'>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>
{selectedStream.stream_type === 'audio' ? 'Audio stream' : 'Video stream'}
</div>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>
Starts {formatMediaDate(selectedStream.starts_at)}
</div>
{selectedStream.is_demo_stream ? (
<div className='rounded-full border border-amber-400/20 bg-amber-400/10 px-4 py-2 text-amber-100'>
Demo playback source enabled
</div>
) : null}
</div>
</>
) : (
<div className='rounded-[24px] border border-white/10 bg-white/5 p-6 text-sm text-slate-300'>
No public streams are available yet.
</div>
)}
</div>
</div>
<aside className='rounded-[30px] border border-white/10 bg-white/5 p-6'>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Stream details</p>
{selectedStream ? (
<div className='mt-6 space-y-5 text-sm text-slate-300'>
<div>
<p className='text-slate-500'>Description</p>
<p className='mt-2 leading-6 text-white'>{selectedStream.description || 'No stream description is available yet.'}</p>
</div>
<div>
<p className='text-slate-500'>Type</p>
<p className='mt-1 text-white'>{humanizeMediaKind(selectedStream.stream_type)}</p>
</div>
<div>
<p className='text-slate-500'>Starts</p>
<p className='mt-1 text-white'>{formatMediaDate(selectedStream.starts_at)}</p>
</div>
{selectedStream.ends_at ? (
<div>
<p className='text-slate-500'>Ends</p>
<p className='mt-1 text-white'>{formatMediaDate(selectedStream.ends_at)}</p>
</div>
) : null}
{selectedStream.host?.name ? (
<div>
<p className='text-slate-500'>Host</p>
<p className='mt-1 text-white'>{selectedStream.host.name}</p>
</div>
) : null}
{selectedStream.category?.name ? (
<div>
<p className='text-slate-500'>Category</p>
<p className='mt-1 text-white'>{selectedStream.category.name}</p>
</div>
) : null}
{selectedStream.playback_note ? (
<div className='rounded-[22px] border border-amber-400/20 bg-amber-400/10 p-4 text-amber-100'>
{selectedStream.playback_note}
</div>
) : null}
{selectedStream.original_stream_url && selectedStream.is_demo_stream ? (
<div className='rounded-[22px] border border-white/10 bg-slate-950/40 p-4'>
<p className='text-slate-500'>Original configured source</p>
<p className='mt-2 break-all text-xs text-slate-300'>{selectedStream.original_stream_url}</p>
</div>
) : null}
</div>
) : (
<p className='mt-6 text-sm text-slate-300'>Select a stream to see its details.</p>
)}
</aside>
</div>
</div>
</section>
<section className='bg-[#050816]'>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<div className='flex flex-col gap-3 md:flex-row md:items-end md:justify-between'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Lineup</p>
<h2 className='mt-3 text-3xl font-semibold text-white'>Choose a stream to play.</h2>
</div>
<div className='inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300'>
<BaseIcon path={mdiBroadcast} size={18} className='text-cyan-200' />
{streams.length} public stream{streams.length === 1 ? '' : 's'} available
</div>
</div>
<div className='mt-8 grid gap-5 lg:grid-cols-3'>
{streams.map((stream) => {
const isSelected = stream.id === selectedStream?.id;
const IconPath = stream.stream_type === 'audio' ? mdiRadioTower : mdiTelevisionClassic;
return (
<button
key={stream.id}
type='button'
onClick={() => setSelectedStreamId(stream.id)}
className={`rounded-[26px] border p-5 text-left transition ${isSelected ? 'border-cyan-300/40 bg-cyan-300/10 shadow-lg shadow-cyan-950/20' : 'border-white/10 bg-white/5 hover:border-white/20 hover:bg-white/[0.08]'}`}
>
<div className='flex items-start justify-between gap-4'>
<div className='rounded-2xl border border-white/10 bg-slate-950/50 p-3'>
<BaseIcon path={IconPath} size={28} className='text-cyan-200' />
</div>
<span className={`rounded-full px-3 py-1 text-[11px] font-medium uppercase tracking-[0.22em] ${getPublicStatusClass(stream.status)}`}>
{stream.status}
</span>
</div>
<h3 className='mt-5 text-xl font-semibold text-white'>{stream.title}</h3>
<p className='mt-3 text-sm leading-6 text-slate-300'>{stream.description || 'No description yet for this stream.'}</p>
<div className='mt-5 flex flex-wrap gap-2 text-xs uppercase tracking-[0.18em] text-slate-400'>
<span>{humanizeMediaKind(stream.stream_type)}</span>
<span></span>
<span>{formatMediaDate(stream.starts_at, false)}</span>
</div>
<div className='mt-5 flex items-center gap-2 text-sm text-cyan-200'>
<BaseIcon path={mdiPlayCircleOutline} size={18} />
{stream.stream_type === 'audio' ? 'Listen to stream' : 'Watch stream'}
</div>
{stream.is_demo_stream ? (
<div className='mt-4 rounded-full border border-amber-400/20 bg-amber-400/10 px-3 py-1 text-xs text-amber-100'>
Demo playback source
</div>
) : null}
</button>
);
})}
</div>
</div>
</section>
</main>
</>
);
}
PublicLivePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,157 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mdiArrowLeft, mdiMovieOpenPlay, mdiTelevisionClassic } from '@mdi/js';
import BaseIcon from '../../../components/BaseIcon';
import LoadingSpinner from '../../../components/LoadingSpinner';
import { getPageTitle } from '../../../config';
import { formatDuration, formatMediaDate, humanizeMediaKind } from '../../../helpers/publicMedia';
import LayoutGuest from '../../../layouts/Guest';
export default function PublicShowDetailsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [show, setShow] = useState<any>(null);
useEffect(() => {
if (!router.isReady || typeof router.query.id !== 'string') return;
let isMounted = true;
const loadShow = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get(`/public-media/shows/${router.query.id}`);
if (isMounted) {
setShow(response.data);
}
} catch (error) {
console.error('Failed to load public show:', error);
if (isMounted) {
setErrorMessage('We could not load this show right now.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadShow();
return () => {
isMounted = false;
};
}, [router.isReady, router.query.id]);
return (
<>
<Head>
<title>{getPageTitle(show?.title || 'Show')}</title>
<meta name='description' content={show?.summary || 'Public show detail page for Aliyo Momot.'} />
</Head>
<main className='min-h-screen bg-[#050816] text-white'>
{loading ? (
<LoadingSpinner />
) : errorMessage ? (
<div className='mx-auto max-w-4xl px-6 py-16 lg:px-10'>
<div className='rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-100'>{errorMessage}</div>
</div>
) : !show ? (
<div className='mx-auto max-w-4xl px-6 py-16 text-slate-300 lg:px-10'>Show not found.</div>
) : (
<>
<section
className='border-b border-white/5 bg-[linear-gradient(135deg,_#050816_0%,_#0f172a_55%,_#111827_100%)]'
style={show.bannerImageUrl ? { backgroundImage: `linear-gradient(135deg, rgba(5,8,22,0.92), rgba(15,23,42,0.92), rgba(17,24,39,0.92)), url(${show.bannerImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' } : undefined}
>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<Link href='/watch/shows' className='inline-flex items-center gap-2 text-sm text-cyan-200 transition hover:text-white'>
<BaseIcon path={mdiArrowLeft} size={16} /> Back to shows
</Link>
<div className='mt-8 grid gap-8 lg:grid-cols-[260px_1fr] lg:items-start'>
<div className='overflow-hidden rounded-[28px] border border-white/10 bg-white/5'>
{show.posterImageUrl ? (
<img src={show.posterImageUrl} alt={show.title} className='h-full w-full object-cover' />
) : (
<div className='flex aspect-[4/5] items-center justify-center bg-slate-900 text-cyan-200'>
<BaseIcon path={mdiTelevisionClassic} size={54} />
</div>
)}
</div>
<div>
<div className='inline-flex rounded-full border border-white/10 bg-white/10 px-4 py-2 text-xs uppercase tracking-[0.24em] text-cyan-100'>
{humanizeMediaKind(show.show_type)}
</div>
<h1 className='mt-5 text-4xl font-semibold text-white sm:text-5xl'>{show.title}</h1>
<p className='mt-5 max-w-3xl text-base leading-8 text-slate-300'>{show.summary || 'This show is now available in the public watch experience.'}</p>
<div className='mt-8 flex flex-wrap gap-3 text-sm text-slate-300'>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{show.category?.name || 'Uncategorized'}</div>
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{show.release_year || 'Current programming'}</div>
{show.owner?.name && <div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>Hosted by {show.owner.name}</div>}
</div>
</div>
</div>
</div>
</section>
<section>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<div className='flex items-end justify-between gap-6'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Episode archive</p>
<h2 className='mt-3 text-3xl font-semibold text-white'>Published episodes for this show.</h2>
</div>
<Link href='/watch/episodes' className='text-sm text-cyan-200 transition hover:text-white'>
Browse all episodes
</Link>
</div>
{show.episodes?.length ? (
<div className='mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
{show.episodes.map((episode) => (
<Link key={episode.id} href={`/watch/episodes/${episode.id}`} className='rounded-[28px] border border-white/10 bg-white/5 p-6 transition hover:-translate-y-1 hover:border-fuchsia-300/35 hover:bg-white/[0.08]'>
<div className='flex items-start justify-between gap-4'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>
<BaseIcon path={mdiMovieOpenPlay} size={28} className='text-fuchsia-200' />
</div>
<div className='rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300'>
S{episode.season_number || 1} E{episode.episode_number || 1}
</div>
</div>
<h3 className='mt-6 text-2xl font-semibold text-white'>{episode.title}</h3>
<p className='mt-3 line-clamp-3 text-sm leading-6 text-slate-300'>{episode.description || 'Open the episode detail page for playback information.'}</p>
<div className='mt-6 flex flex-wrap gap-2 text-sm text-slate-300'>
<span>{formatMediaDate(episode.published_at, false)}</span>
<span className='text-slate-500'></span>
<span>{formatDuration(episode.duration_seconds)}</span>
</div>
</Link>
))}
</div>
) : (
<div className='mt-8 rounded-[24px] border border-white/10 bg-white/5 p-8 text-center text-slate-300'>
No published episodes are attached to this show yet.
</div>
)}
</div>
</section>
</>
)}
</main>
</>
);
}
PublicShowDetailsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,205 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mdiMagnify, mdiTelevisionClassic } from '@mdi/js';
import BaseIcon from '../../../components/BaseIcon';
import LoadingSpinner from '../../../components/LoadingSpinner';
import { getPageTitle } from '../../../config';
import { humanizeMediaKind } from '../../../helpers/publicMedia';
import LayoutGuest from '../../../layouts/Guest';
export default function PublicShowsPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [shows, setShows] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
useEffect(() => {
if (!router.isReady) return;
const categoryFromQuery = typeof router.query.categoryId === 'string' ? router.query.categoryId : '';
const queryFromUrl = typeof router.query.q === 'string' ? router.query.q : '';
setSelectedCategory(categoryFromQuery);
setSearchTerm(queryFromUrl);
}, [router.isReady, router.query.categoryId, router.query.q]);
useEffect(() => {
let isMounted = true;
const loadCategories = async () => {
try {
const response = await axios.get('/public-media/categories', { params: { limit: 24 } });
if (isMounted) {
setCategories(response.data?.rows || []);
}
} catch (error) {
console.error('Failed to load public categories:', error);
}
};
loadCategories();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadShows = async () => {
setLoading(true);
setErrorMessage('');
try {
const response = await axios.get('/public-media/shows', {
params: {
limit: 24,
q: searchTerm || undefined,
categoryId: selectedCategory || undefined,
},
});
if (isMounted) {
setShows(response.data?.rows || []);
}
} catch (error) {
console.error('Failed to load public shows:', error);
if (isMounted) {
setErrorMessage('We could not load shows right now.');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadShows();
return () => {
isMounted = false;
};
}, [searchTerm, selectedCategory]);
const applyCategory = (categoryId: string) => {
setSelectedCategory(categoryId);
void router.replace(
{
pathname: '/watch/shows',
query: {
...(searchTerm ? { q: searchTerm } : {}),
...(categoryId ? { categoryId } : {}),
},
},
undefined,
{ shallow: true },
);
};
return (
<>
<Head>
<title>{getPageTitle('Shows')}</title>
<meta name='description' content='Browse all published Aliyo Momot shows by category and keyword.' />
</Head>
<main className='min-h-screen bg-[#050816] text-white'>
<section className='border-b border-white/5 bg-[linear-gradient(135deg,_#050816_0%,_#0f172a_55%,_#111827_100%)]'>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
<Link href='/watch' className='text-sm text-cyan-200 transition hover:text-white'>
Back to watch hub
</Link>
<div className='mt-6 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between'>
<div>
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Shows library</p>
<h1 className='mt-3 text-4xl font-semibold text-white sm:text-5xl'>Public shows, organized for discovery.</h1>
<p className='mt-4 max-w-3xl text-base leading-7 text-slate-300'>
Filter the published program lineup by keyword or category, then open a show to view its public episode archive.
</p>
</div>
</div>
<div className='mt-8 rounded-[28px] border border-white/10 bg-white/5 p-5'>
<label className='flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/60 px-4 py-3'>
<BaseIcon path={mdiMagnify} size={20} className='text-cyan-200' />
<input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder='Search show titles or summaries'
className='w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-500'
/>
</label>
<div className='mt-4 flex flex-wrap gap-3'>
<button
type='button'
onClick={() => applyCategory('')}
className={`rounded-full px-4 py-2 text-sm transition ${selectedCategory ? 'border border-white/10 bg-white/5 text-slate-300 hover:text-white' : 'bg-white text-slate-950'}`}
>
All categories
</button>
{categories.map((category) => (
<button
key={category.id}
type='button'
onClick={() => applyCategory(category.id)}
className={`rounded-full px-4 py-2 text-sm transition ${selectedCategory === category.id ? 'bg-cyan-300 text-slate-950' : 'border border-white/10 bg-white/5 text-slate-300 hover:text-white'}`}
>
{category.name}
</button>
))}
</div>
</div>
</div>
</section>
<section>
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
{loading ? (
<LoadingSpinner />
) : errorMessage ? (
<div className='rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-100'>{errorMessage}</div>
) : shows.length ? (
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
{shows.map((show) => (
<Link key={show.id} href={`/watch/shows/${show.id}`} className='group rounded-[28px] border border-white/10 bg-white/5 p-6 transition hover:-translate-y-1 hover:border-cyan-300/35 hover:bg-white/[0.08]'>
<div className='flex items-start justify-between gap-4'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-3'>
<BaseIcon path={mdiTelevisionClassic} size={28} className='text-cyan-200' />
</div>
<div className='rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-slate-300'>
{humanizeMediaKind(show.show_type)}
</div>
</div>
<h2 className='mt-6 text-2xl font-semibold text-white'>{show.title}</h2>
<p className='mt-3 line-clamp-3 text-sm leading-6 text-slate-300'>{show.summary || 'Published show ready for viewers.'}</p>
<div className='mt-6 flex flex-wrap gap-2 text-xs uppercase tracking-[0.18em] text-slate-400'>
<span>{show.category?.name || 'Uncategorized'}</span>
<span></span>
<span>{show.release_year || 'Current'}</span>
</div>
</Link>
))}
</div>
) : (
<div className='rounded-[24px] border border-white/10 bg-white/5 p-8 text-center text-slate-300'>
No published shows matched your current filters.
</div>
)}
</div>
</section>
</main>
</>
);
}
PublicShowsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

File diff suppressed because it is too large Load Diff