Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83fcbfd315 | ||
|
|
14079e71ec | ||
|
|
d2d9f2b743 | ||
|
|
0dfcc2039f | ||
|
|
3ea6b2dc42 | ||
|
|
bfc0c7768a |
@ -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 = [
|
||||
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
582
backend/src/routes/public-media.js
Normal file
582
backend/src/routes/public-media.js
Normal 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;
|
||||
@ -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
12121
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
1125
frontend/src/components/MediaCenterUploadWidget.tsx
Normal file
1125
frontend/src/components/MediaCenterUploadWidget.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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'
|
||||
|
||||
257
frontend/src/components/PublicMediaPlayer.tsx
Normal file
257
frontend/src/components/PublicMediaPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/PublicNowPlayingDock.tsx
Normal file
118
frontend/src/components/PublicNowPlayingDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
frontend/src/helpers/publicMedia.ts
Normal file
192
frontend/src/helpers/publicMedia.ts
Normal 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;
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'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>;
|
||||
};
|
||||
|
||||
|
||||
731
frontend/src/pages/media-center.tsx
Normal file
731
frontend/src/pages/media-center.tsx
Normal 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;
|
||||
216
frontend/src/pages/watch/episodes/[id].tsx
Normal file
216
frontend/src/pages/watch/episodes/[id].tsx
Normal 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>;
|
||||
};
|
||||
214
frontend/src/pages/watch/episodes/index.tsx
Normal file
214
frontend/src/pages/watch/episodes/index.tsx
Normal 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>;
|
||||
};
|
||||
307
frontend/src/pages/watch/index.tsx
Normal file
307
frontend/src/pages/watch/index.tsx
Normal 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>;
|
||||
};
|
||||
336
frontend/src/pages/watch/live.tsx
Normal file
336
frontend/src/pages/watch/live.tsx
Normal 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>;
|
||||
};
|
||||
157
frontend/src/pages/watch/shows/[id].tsx
Normal file
157
frontend/src/pages/watch/shows/[id].tsx
Normal 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>;
|
||||
};
|
||||
205
frontend/src/pages/watch/shows/index.tsx
Normal file
205
frontend/src/pages/watch/shows/index.tsx
Normal 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>;
|
||||
};
|
||||
1483
frontend/yarn.lock
1483
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user