Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d89ab1624 | ||
|
|
7bd694394a | ||
|
|
06355b7590 | ||
|
|
68f073ed42 | ||
|
|
f6306d622f | ||
|
|
6f6f37b7f6 | ||
|
|
0585f4a7f8 | ||
|
|
ef024c0a05 | ||
|
|
12aa57ccc9 | ||
|
|
9ecefd75f3 | ||
|
|
a867eecaa5 | ||
|
|
43f81d3a49 | ||
|
|
4d37de3455 | ||
|
|
0f8066f06d | ||
|
|
dc373e5a53 | ||
|
|
e663cabe9c | ||
|
|
b5714061f8 | ||
|
|
4db142c2f2 | ||
|
|
73455aae28 | ||
|
|
e66f49e54a | ||
|
|
3e44947597 | ||
|
|
07e9d9838d | ||
|
|
f01ffc6254 | ||
|
|
6057bcea7d | ||
|
|
7b903d4bb4 | ||
|
|
7c1a1d5672 | ||
|
|
1891498563 | ||
|
|
7484df1e5b | ||
|
|
52bfdbcdce | ||
|
|
6b3a698283 | ||
|
|
32c0d6a6d3 | ||
|
|
97b6c4bee7 | ||
|
|
032b9d8116 | ||
|
|
6e6fd67fea | ||
|
|
5fb3bb2ebc | ||
|
|
d1cc12e065 | ||
|
|
e6df6ac10a | ||
|
|
e282bcecfa | ||
|
|
b183c6a154 | ||
|
|
c0b20a0ac5 | ||
|
|
482396497e | ||
|
|
4229717575 | ||
|
|
0826198d21 |
@@ -50,6 +50,37 @@ jobs:
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
@@ -51,3 +52,6 @@ scripts/download-test-content.sh
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
ErsatzTV/wwwroot/app/
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
|
||||
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
|
||||
/// </summary>
|
||||
public static class ChannelGuideMetadata
|
||||
{
|
||||
public static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The primary guide category, mirroring the fixed <c><category></c> the XMLTV templates
|
||||
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
|
||||
/// </summary>
|
||||
public static string GetCategory(PlayoutItem playoutItem) =>
|
||||
playoutItem.MediaItem switch
|
||||
{
|
||||
Movie => "Movie",
|
||||
Episode => "Series",
|
||||
MusicVideo => "Music",
|
||||
Song => "Music",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
|
||||
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
|
||||
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
|
||||
/// </summary>
|
||||
public readonly record struct ChannelGuideEntry(
|
||||
PlayoutItem DisplayItem,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Stop,
|
||||
bool HasCustomTitle);
|
||||
|
||||
/// <summary>
|
||||
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
|
||||
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
|
||||
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
|
||||
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
|
||||
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
|
||||
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
|
||||
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
|
||||
/// </summary>
|
||||
public static class ChannelGuideProjector
|
||||
{
|
||||
public static IEnumerable<ChannelGuideEntry> Project(
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior) =>
|
||||
scheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
|
||||
_ => ProjectFlood(sorted, timeZone)
|
||||
};
|
||||
|
||||
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
|
||||
// each guide group (following filler) into a single programme using the display item's GuideFinish
|
||||
// override when present.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone)
|
||||
{
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
DateTimeOffset startTime = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
|
||||
_ => startItem.StartOffset
|
||||
};
|
||||
|
||||
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
|
||||
{
|
||||
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
|
||||
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
|
||||
(_, true) => displayItem.GuideFinishOffset!.Value,
|
||||
(_, false) => finishItem.FinishOffset
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
|
||||
// split the group window evenly across the non-filler items.
|
||||
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
|
||||
IReadOnlyList<PlayoutItem> sorted,
|
||||
XmltvTimeZone timeZone,
|
||||
XmltvBlockBehavior blockBehavior)
|
||||
{
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (blockBehavior)
|
||||
{
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset actualFinish = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
|
||||
}
|
||||
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = timeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkDeleteChannelsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkDeleteChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
dbContext.Channels.RemoveRange(channels);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkMoveChannelsToGroupHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkMoveChannelsToGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
|
||||
if (groupValidation.IsFail)
|
||||
{
|
||||
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
channel.Group = request.Group;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.IncludeGuideMetadata()
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
await WriteScheduleXml(
|
||||
request,
|
||||
playout.ScheduleKind,
|
||||
floodSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
@@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WriteBlockPlayoutXml(
|
||||
await WriteScheduleXml(
|
||||
request,
|
||||
playout.ScheduleKind,
|
||||
blockSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
@@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
item.Finish += playoutOffset;
|
||||
}
|
||||
|
||||
await WritePlayoutXml(
|
||||
await WriteScheduleXml(
|
||||
request,
|
||||
playout.ScheduleKind,
|
||||
externalJsonSorted,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
@@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WritePlayoutXml(
|
||||
RefreshChannelData request,
|
||||
List<PlayoutItem> sorted,
|
||||
XmlTemplateContext templateContext,
|
||||
Template movieTemplate,
|
||||
Template episodeTemplate,
|
||||
Template musicVideoTemplate,
|
||||
Template songTemplate,
|
||||
Template otherVideoTemplate,
|
||||
Template remoteStreamTemplate,
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
XmltvTimeZone xmltvTimeZone = await _configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
// skip all filler that isn't pre-roll
|
||||
var i = 0;
|
||||
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
|
||||
sorted[i].FillerKind != FillerKind.PreRoll)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < sorted.Count)
|
||||
{
|
||||
PlayoutItem startItem = sorted[i];
|
||||
int j = i;
|
||||
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
|
||||
{
|
||||
j++;
|
||||
}
|
||||
|
||||
PlayoutItem displayItem = sorted[j];
|
||||
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
|
||||
|
||||
int finishIndex = j;
|
||||
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|
||||
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
|
||||
or FillerKind.PostRoll or FillerKind.Tail
|
||||
or FillerKind.Fallback or FillerKind.DecoDefault))
|
||||
{
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
DateTimeOffset startTime = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
|
||||
_ => startItem.StartOffset
|
||||
};
|
||||
|
||||
DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch
|
||||
{
|
||||
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
|
||||
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
|
||||
(_, true) => displayItem.GuideFinishOffset!.Value,
|
||||
(_, false) => finishItem.FinishOffset
|
||||
};
|
||||
|
||||
string start = startTime
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
string stop = stopTime
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
displayItem,
|
||||
start,
|
||||
stop,
|
||||
hasCustomTitle,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteBlockPlayoutXml(
|
||||
private async Task WriteScheduleXml(
|
||||
RefreshChannelData request,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
List<PlayoutItem> sorted,
|
||||
XmlTemplateContext templateContext,
|
||||
Template movieTemplate,
|
||||
@@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
||||
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
||||
|
||||
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
|
||||
foreach (var group in groups)
|
||||
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
|
||||
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
|
||||
scheduleKind,
|
||||
sorted,
|
||||
xmltvTimeZone,
|
||||
xmltvBlockBehavior))
|
||||
{
|
||||
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
|
||||
if (itemsToInclude.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string start = entry.Start
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
switch (xmltvBlockBehavior)
|
||||
{
|
||||
case XmltvBlockBehavior.UseActualTimes:
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
DateTimeOffset actualStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
string stop = entry.Stop
|
||||
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
DateTimeOffset actualFinish = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
break;
|
||||
case XmltvBlockBehavior.SplitTimeEvenly:
|
||||
default:
|
||||
DateTime groupStart = group.Key.GuideStart!.Value;
|
||||
DateTime groupFinish = group.Key.GuideFinish!.Value;
|
||||
TimeSpan groupDuration = groupFinish - groupStart;
|
||||
|
||||
TimeSpan perItem = groupDuration / itemsToInclude.Count;
|
||||
|
||||
DateTimeOffset currentStart = xmltvTimeZone switch
|
||||
{
|
||||
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
|
||||
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
|
||||
};
|
||||
|
||||
DateTimeOffset currentFinish = currentStart + perItem;
|
||||
|
||||
foreach (PlayoutItem item in itemsToInclude)
|
||||
{
|
||||
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
|
||||
.Replace(":", string.Empty);
|
||||
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
item,
|
||||
start,
|
||||
stop,
|
||||
false,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
|
||||
currentStart = currentFinish;
|
||||
currentFinish += perItem;
|
||||
}
|
||||
break;
|
||||
}
|
||||
await WriteItemToXml(
|
||||
request,
|
||||
entry.DisplayItem,
|
||||
start,
|
||||
stop,
|
||||
entry.HasCustomTitle,
|
||||
templateContext,
|
||||
movieTemplate,
|
||||
episodeTemplate,
|
||||
musicVideoTemplate,
|
||||
songTemplate,
|
||||
otherVideoTemplate,
|
||||
remoteStreamTemplate,
|
||||
minifier,
|
||||
xml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
XmlMinifier minifier,
|
||||
XmlWriter xml)
|
||||
{
|
||||
string title = GetTitle(displayItem);
|
||||
string subtitle = GetSubtitle(displayItem);
|
||||
string title = ChannelGuideMetadata.GetTitle(displayItem);
|
||||
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
|
||||
|
||||
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
|
||||
{
|
||||
@@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
private static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return playoutItem.CustomTitle;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
|
||||
.IfNone("[unknown artist]"),
|
||||
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown video]"),
|
||||
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
|
||||
.IfNone("[unknown remote stream]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSubtitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
Song s => s.SongMetadata.HeadOrNone().Match(
|
||||
mvm => mvm.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPrioritizedArtworkPath(Metadata metadata)
|
||||
{
|
||||
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> validationError = ValidateRequest(request);
|
||||
if (validationError.IsSome)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
var channelIds = numberUpdates.Keys;
|
||||
List<int> channelIds = numberUpdates.Keys.ToList();
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channelsToUpdate.Count != channelIds.Count)
|
||||
{
|
||||
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return new NotFoundError($"Channel {missingId} does not exist.");
|
||||
}
|
||||
|
||||
List<string> requestedNumbers = numberUpdates.Values.ToList();
|
||||
bool numberConflict = await dbContext.Channels
|
||||
.AnyAsync(
|
||||
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
|
||||
cancellationToken);
|
||||
if (numberConflict)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
// give every channel a non-conflicting number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
|
||||
{
|
||||
if (request.Channels.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one channel is required");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel ids must be unique");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
foreach (ChannelSortViewModel channel in request.Channels)
|
||||
{
|
||||
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,15 @@ internal static class Mapper
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.SortNumber,
|
||||
channel.Name,
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfile.Name,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel));
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// JSON channel-guide query for the EPG grid. <paramref name="Start" /> defaults to now and
|
||||
/// <paramref name="End" /> defaults to now + the configured XmltvDaysToBuild window.
|
||||
/// </summary>
|
||||
public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
|
||||
: IRequest<ChannelGuideResponseModel>;
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
|
||||
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
|
||||
/// cache builder uses) so the two representations cannot drift. Only channels with
|
||||
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
|
||||
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
|
||||
/// so the UI can decide how to render them.
|
||||
/// </summary>
|
||||
public class GetChannelGuideDataHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
|
||||
{
|
||||
public async Task<ChannelGuideResponseModel> Handle(
|
||||
GetChannelGuideData request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
int daysToBuild = await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
XmltvTimeZone xmltvTimeZone = await configElementRepository
|
||||
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
|
||||
.IfNoneAsync(XmltvTimeZone.Local);
|
||||
|
||||
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
|
||||
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
|
||||
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
|
||||
|
||||
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
|
||||
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
|
||||
|
||||
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.ShowInEpg)
|
||||
.Include(c => c.MirrorSourceChannel)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Order channels by their decimal channel number so "2" precedes "10", matching
|
||||
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
|
||||
channels = channels
|
||||
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
|
||||
var responseChannels = new List<ChannelGuideChannelResponseModel>();
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
&& channel.MirrorSourceChannel is not null;
|
||||
|
||||
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
|
||||
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(p => p.Channel.Number == sourceChannelNumber)
|
||||
.IncludeGuideMetadata()
|
||||
.AsSplitQuery()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var programmes = new List<ChannelGuideProgrammeResponseModel>();
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
|
||||
// out of scope for the JSON guide (see issue #102 notes).
|
||||
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
|
||||
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
|
||||
List<PlayoutItem> sorted = playout.Items
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Filter(pi => pi.StartOffset <= end)
|
||||
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
|
||||
.ToList();
|
||||
|
||||
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
|
||||
playout.ScheduleKind,
|
||||
sorted,
|
||||
xmltvTimeZone,
|
||||
xmltvBlockBehavior))
|
||||
{
|
||||
// drop programmes that finish before the requested window starts
|
||||
if (entry.Stop <= start)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
|
||||
|
||||
programmes.Add(
|
||||
new ChannelGuideProgrammeResponseModel(
|
||||
entry.Start,
|
||||
entry.Stop,
|
||||
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
|
||||
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
|
||||
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
|
||||
entry.DisplayItem.FillerKind));
|
||||
}
|
||||
}
|
||||
|
||||
responseChannels.Add(
|
||||
new ChannelGuideChannelResponseModel(
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
programmes.OrderBy(p => p.Start).ToList()));
|
||||
}
|
||||
|
||||
return new ChannelGuideResponseModel(start, end, responseChannels);
|
||||
}
|
||||
|
||||
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
|
||||
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
|
||||
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
|
||||
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
|
||||
new()
|
||||
{
|
||||
MediaItem = item.MediaItem,
|
||||
Start = item.Start + offset,
|
||||
Finish = item.Finish + offset,
|
||||
GuideStart = item.GuideStart,
|
||||
GuideFinish = item.GuideFinish,
|
||||
GuideGroup = item.GuideGroup,
|
||||
FillerKind = item.FillerKind,
|
||||
CustomTitle = item.CustomTitle
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
|
||||
@@ -0,0 +1,151 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
|
||||
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
|
||||
|
||||
public async Task<List<ChannelStateResponseModel>> Handle(
|
||||
GetChannelStatesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.SortNumber)
|
||||
.ThenBy(c => c.Number)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
|
||||
.ToDictionary(
|
||||
c => c.Id,
|
||||
c =>
|
||||
{
|
||||
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
|
||||
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
|
||||
});
|
||||
|
||||
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
|
||||
// typically just one) with a cheap projection; metadata is hydrated in a second query
|
||||
// for only the covering items' guide groups
|
||||
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
|
||||
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
|
||||
channelLookup.Values.GroupBy(v => v.LookupTime))
|
||||
{
|
||||
DateTime lookupTime = timeGroup.Key;
|
||||
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
|
||||
|
||||
List<CoveringItem> covering = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
|
||||
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
|
||||
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
|
||||
{
|
||||
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
|
||||
if (coveringBySourceAndTime.Count > 0)
|
||||
{
|
||||
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
|
||||
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
|
||||
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
|
||||
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
|
||||
|
||||
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MovieMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image).ImageMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
||||
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
|
||||
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
itemsByGuideEntry = guideEntryItems
|
||||
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
|
||||
}
|
||||
|
||||
return channels
|
||||
.Map(channel =>
|
||||
{
|
||||
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
|
||||
ChannelNowPlayingResponseModel nowPlaying = null;
|
||||
|
||||
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
|
||||
itemsByGuideEntry.TryGetValue(
|
||||
(covering.PlayoutId, covering.GuideGroup),
|
||||
out List<PlayoutItem> guideEntry))
|
||||
{
|
||||
// like the XMLTV guide, surface the program rather than its filler: use the
|
||||
// covering item itself when it is a program part, otherwise the guide entry's
|
||||
// first program item; an entry with no program item (e.g. offline fallback
|
||||
// filler) stays null
|
||||
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
|
||||
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
|
||||
? coveringItem
|
||||
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
|
||||
|
||||
if (displayItem is not null)
|
||||
{
|
||||
DateTime start = guideEntry[0].Start;
|
||||
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
|
||||
|
||||
nowPlaying = new ChannelNowPlayingResponseModel(
|
||||
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
|
||||
new DateTimeOffset(start + offset, TimeSpan.Zero),
|
||||
new DateTimeOffset(finish + offset, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
/// <summary>A single guide programme for the JSON EPG grid.</summary>
|
||||
public record ChannelGuideProgrammeResponseModel(
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Stop,
|
||||
string Title,
|
||||
string? SubTitle,
|
||||
string? Category,
|
||||
FillerKind FillerKind);
|
||||
|
||||
/// <summary>One channel's guide programmes for the requested window.</summary>
|
||||
public record ChannelGuideChannelResponseModel(
|
||||
string Number,
|
||||
string Name,
|
||||
List<ChannelGuideProgrammeResponseModel> Programmes);
|
||||
|
||||
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
|
||||
public record ChannelGuideResponseModel(
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset End,
|
||||
List<ChannelGuideChannelResponseModel> Channels);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelNowPlayingResponseModel(
|
||||
string Title,
|
||||
DateTimeOffset StartUtc,
|
||||
DateTimeOffset FinishUtc);
|
||||
@@ -1,12 +1,18 @@
|
||||
using Newtonsoft.Json;
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
public record ChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
double SortNumber,
|
||||
string Name,
|
||||
string Group,
|
||||
string Categories,
|
||||
[property: JsonProperty("ffmpegProfile")]
|
||||
string FFmpegProfile,
|
||||
string Language,
|
||||
string StreamingMode);
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelStateResponseModel(
|
||||
int ChannelId,
|
||||
string ChannelNumber,
|
||||
bool OnAir,
|
||||
ChannelNowPlayingResponseModel? NowPlaying);
|
||||
@@ -0,0 +1,98 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Extensions;
|
||||
|
||||
public static class PlayoutGuideQueryableExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Eager-loads the full playout-item metadata graph needed to render guide programme
|
||||
/// titles/subtitles/categories/artwork. Shared by the XMLTV cache builder and the JSON guide
|
||||
/// query so both surfaces see identical data. Callers should apply <c>AsSplitQuery()</c>.
|
||||
/// </summary>
|
||||
public static IQueryable<Playout> IncludeGuideMetadata(this IQueryable<Playout> playouts) =>
|
||||
playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Directors)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(vm => vm.Artwork)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Studios);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private readonly MockFileSystem _fileSystem = new();
|
||||
|
||||
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Delete_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
int count = await context.Channels.CountAsync();
|
||||
count.ShouldBe(0);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
|
||||
exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Move_All_Channels_To_Group()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> groups = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => c.Group)
|
||||
.ToListAsync();
|
||||
groups.ShouldBe(["Movies", "Movies"]);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetAllChannelsForApiHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Project_Management_Table_Fields()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetAll(Arg.Any<CancellationToken>())
|
||||
.Returns([
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Id = 7,
|
||||
Number = "7.1",
|
||||
SortNumber = 7.1,
|
||||
Name = "Retro Cartoons",
|
||||
Group = "Kids",
|
||||
Categories = "animation",
|
||||
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
||||
IsEnabled = false,
|
||||
ShowInEpg = false
|
||||
}
|
||||
]);
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
ChannelResponseModel channel = result.ShouldHaveSingleItem();
|
||||
channel.Id.ShouldBe(7);
|
||||
channel.Number.ShouldBe("7.1");
|
||||
channel.SortNumber.ShouldBe(7.1);
|
||||
channel.Name.ShouldBe("Retro Cartoons");
|
||||
channel.Group.ShouldBe("Kids");
|
||||
channel.Categories.ShouldBe("animation");
|
||||
channel.FFmpegProfile.ShouldBe("HLS 720p");
|
||||
channel.Language.ShouldBe("eng");
|
||||
channel.StreamingMode.ShouldBe("HLS Segmenter");
|
||||
channel.IsEnabled.ShouldBeFalse();
|
||||
channel.ShowInEpg.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using DomainChannel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelGuideDataHandlerTests
|
||||
{
|
||||
private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _config = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_config = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
// ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
|
||||
// match by the generic value type (GetValue<T>) with Arg.Any key. Pin the time zone to UTC so
|
||||
// projected times are deterministic regardless of the machine/CI time zone, and split block time
|
||||
// evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test.
|
||||
_config.GetValue<XmltvTimeZone>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<XmltvTimeZone>.Some(XmltvTimeZone.Utc));
|
||||
_config.GetValue<XmltvBlockBehavior>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<XmltvBlockBehavior>.Some(XmltvBlockBehavior.SplitTimeEvenly));
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config);
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Only_Include_Visible_Channels()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel visible = NewChannel("2", "Visible", showInEpg: true);
|
||||
DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false);
|
||||
visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))];
|
||||
hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))];
|
||||
context.Channels.AddRange(visible, hidden);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Order_Channels_By_Decimal_Number()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Channels.Add(NewChannel("10", "Ten", showInEpg: true));
|
||||
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
|
||||
context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
// decimal order: 2 < 5.1 < 10 (string order would put "10" first)
|
||||
result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Filter_Programmes_To_Requested_Window()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
channel.Playouts =
|
||||
[
|
||||
MakeFloodPlayout(
|
||||
channel,
|
||||
(BaseTime, BaseTime.AddHours(1), 1, "Before Window"),
|
||||
(BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window"))
|
||||
];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// window starts after the first programme has finished
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)),
|
||||
CancellationToken.None);
|
||||
|
||||
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
|
||||
programmes.Select(p => p.Title).ShouldBe(["In Window"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored");
|
||||
item.CustomTitle = "Custom Title";
|
||||
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
|
||||
programme.Title.ShouldBe("Custom Title");
|
||||
programme.SubTitle.ShouldBeNull();
|
||||
programme.Category.ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper");
|
||||
preRoll.FillerKind = FillerKind.PreRoll;
|
||||
PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature");
|
||||
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
|
||||
// filler is merged: the programme starts at the pre-roll start but displays the feature metadata
|
||||
programme.Title.ShouldBe("Feature");
|
||||
programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
|
||||
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
|
||||
programme.FillerKind.ShouldBe(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
|
||||
PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One");
|
||||
PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two");
|
||||
foreach (PlayoutItem item in new[] { one, two })
|
||||
{
|
||||
item.GuideStart = BaseTime;
|
||||
item.GuideFinish = BaseTime.AddHours(1);
|
||||
}
|
||||
|
||||
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])];
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
|
||||
programmes.Count.ShouldBe(2);
|
||||
// 1-hour guide window split evenly across the 2 content items -> 30 minutes each
|
||||
programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
|
||||
programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
|
||||
programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
|
||||
programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild()
|
||||
{
|
||||
_config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(3));
|
||||
|
||||
DateTimeOffset before = DateTimeOffset.UtcNow;
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(null, null),
|
||||
CancellationToken.None);
|
||||
DateTimeOffset after = DateTimeOffset.UtcNow;
|
||||
|
||||
result.Start.ShouldBeInRange(before, after);
|
||||
(result.End - result.Start).ShouldBe(TimeSpan.FromDays(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items()
|
||||
{
|
||||
PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show");
|
||||
var playoutOffset = TimeSpan.FromHours(3);
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
DomainChannel source = NewChannel("1", "Source", showInEpg: false);
|
||||
source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])];
|
||||
|
||||
DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true);
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannel = source;
|
||||
mirror.PlayoutOffset = playoutOffset;
|
||||
|
||||
context.Channels.AddRange(source, mirror);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single();
|
||||
programme.Title.ShouldBe("Live Show");
|
||||
programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero));
|
||||
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero));
|
||||
|
||||
// the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's
|
||||
// own (shared, AsNoTracking) PlayoutItem entities in place.
|
||||
sourceItem.Start.ShouldBe(BaseTime);
|
||||
sourceItem.Finish.ShouldBe(BaseTime.AddHours(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelGuideResponseModel result = await MakeHandler().Handle(
|
||||
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Channels.Single().Programmes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// --- seeding helpers ---
|
||||
|
||||
private static Playout MakeFloodPlayout(
|
||||
DomainChannel channel,
|
||||
params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) =>
|
||||
MakePlayout(
|
||||
channel,
|
||||
PlayoutScheduleKind.Classic,
|
||||
items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList());
|
||||
|
||||
private static Playout MakePlayout(
|
||||
DomainChannel channel,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
List<PlayoutItem> items) =>
|
||||
new()
|
||||
{
|
||||
Channel = channel,
|
||||
ScheduleKind = scheduleKind,
|
||||
ScheduleFile = string.Empty,
|
||||
Items = items
|
||||
};
|
||||
|
||||
private static PlayoutItem MakeItem(
|
||||
DateTime start,
|
||||
DateTime finish,
|
||||
int guideGroup,
|
||||
string movieTitle) =>
|
||||
new()
|
||||
{
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
GuideGroup = guideGroup,
|
||||
FillerKind = FillerKind.None,
|
||||
MediaItem = new Movie
|
||||
{
|
||||
MovieMetadata = [new MovieMetadata { Title = movieTitle }],
|
||||
MediaVersions = []
|
||||
}
|
||||
};
|
||||
|
||||
private static DomainChannel NewChannel(string number, string name, bool showInEpg) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Categories = string.Empty,
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
PlayoutMode = ChannelPlayoutMode.Continuous,
|
||||
ShowInEpg = showInEpg,
|
||||
Playouts = []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelStatesForApiHandlerTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IFFmpegSegmenterService _segmenter = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_segmenter = Substitute.For<IFFmpegSegmenterService>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
|
||||
{
|
||||
DateTime start = Now.AddMinutes(-10);
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
await SeedChannelWithMovie(start, finish);
|
||||
_segmenter.IsActive("7.1").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.ChannelId.ShouldBe(7);
|
||||
state.ChannelNumber.ShouldBe("7.1");
|
||||
state.OnAir.ShouldBeTrue();
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Retro Cartoons");
|
||||
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
|
||||
{
|
||||
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
|
||||
{
|
||||
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.OnAir.ShouldBeFalse();
|
||||
state.NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break()
|
||||
{
|
||||
// program part 1 / mid-roll filler / program part 2, all in one guide group
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(12, "12");
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 120,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }]
|
||||
};
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 121,
|
||||
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }]
|
||||
};
|
||||
var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MediaItem = mediaItem,
|
||||
MediaItemId = mediaItem.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
FillerKind = fillerKind,
|
||||
GuideGroup = 1,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.AddRange(
|
||||
MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None),
|
||||
MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll),
|
||||
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Feature Presentation");
|
||||
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero));
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(13, "13");
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 130,
|
||||
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }]
|
||||
};
|
||||
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 132,
|
||||
MediaItem = filler,
|
||||
MediaItemId = filler.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.AddMinutes(-5),
|
||||
Finish = Now.AddMinutes(5),
|
||||
FillerKind = FillerKind.Fallback,
|
||||
GuideGroup = 1,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
|
||||
{
|
||||
DateTime start = Now.AddMinutes(-10);
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(9, "9");
|
||||
var remoteStream = new RemoteStream
|
||||
{
|
||||
Id = 90,
|
||||
RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
|
||||
};
|
||||
var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 92,
|
||||
MediaItem = remoteStream,
|
||||
MediaItemId = remoteStream.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.RemoteStreams.Add(remoteStream);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
ChannelStateResponseModel state =
|
||||
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Live Source");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset()
|
||||
{
|
||||
TimeSpan offset = TimeSpan.FromHours(1);
|
||||
DateTime sourceStart = Now.AddMinutes(-70);
|
||||
DateTime sourceFinish = Now.AddMinutes(-40);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel source = MakeChannel(10, "10");
|
||||
Channel mirror = MakeChannel(11, "11");
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannelId = source.Id;
|
||||
mirror.PlayoutOffset = offset;
|
||||
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 110,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }]
|
||||
};
|
||||
var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 112,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = sourceStart,
|
||||
Finish = sourceFinish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.AddRange(source, mirror);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id);
|
||||
mirrorState.NowPlaying.ShouldNotBeNull();
|
||||
mirrorState.NowPlaying.Title.ShouldBe("Offset Feature");
|
||||
mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
|
||||
mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
|
||||
|
||||
// the source's own item is 40+ minutes in the past with no offset, so its own row is off-air
|
||||
ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id);
|
||||
sourceState.NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(7, "7.1");
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 10,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
|
||||
};
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 20,
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
Items = []
|
||||
};
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 30,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static Channel MakeChannel(int id, string number) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
SortNumber = id,
|
||||
Name = $"Channel {number}",
|
||||
Group = "Test",
|
||||
Categories = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Renumber_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "11.1" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channels = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new { c.Number, c.SortNumber })
|
||||
.ToListAsync();
|
||||
channels[0].Number.ShouldBe("10");
|
||||
channels[0].SortNumber.ShouldBe(10);
|
||||
channels[1].Number.ShouldBe("11.1");
|
||||
channels[1].SortNumber.ShouldBe(11.1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 99, Number = "11" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "10" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("unique");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
|
||||
numbers.ShouldBe(["5", "6"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Invalid channel number");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
private static BaseError ErrorOf(Option<BaseError> result) =>
|
||||
result.Match(
|
||||
Some: error => error,
|
||||
None: () => throw new AssertionException("Expected an error"));
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -35,6 +36,25 @@ public class ChannelControllerTests
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Return_200_With_Channel_State()
|
||||
{
|
||||
var state = new ChannelStateResponseModel(
|
||||
7,
|
||||
"7.1",
|
||||
true,
|
||||
new ChannelNowPlayingResponseModel(
|
||||
"Retro Cartoons",
|
||||
new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
|
||||
_mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([state]);
|
||||
|
||||
List<ChannelStateResponseModel> result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new List<ChannelStateResponseModel> { state });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
@@ -126,6 +146,124 @@ public class ChannelControllerTests
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.BulkRenumber(
|
||||
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateChannelNumbers>(
|
||||
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGuide_Should_Map_Query_And_Return_Model()
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var end = new DateTimeOffset(2026, 1, 3, 0, 0, 0, TimeSpan.Zero);
|
||||
var model = new ChannelGuideResponseModel(start, end, []);
|
||||
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
|
||||
|
||||
ChannelGuideResponseModel result = await _controller.GetGuide(start, end, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(model);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelGuideData>(q => q.Start == start && q.End == end),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.BulkRenumber(
|
||||
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.BulkMoveToGroup(
|
||||
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<BulkMoveChannelsToGroup>(
|
||||
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGuide_Should_Pass_Null_Bounds_Through()
|
||||
{
|
||||
var model = new ChannelGuideResponseModel(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, []);
|
||||
_mediator.Send(Arg.Any<GetChannelGuideData>(), Arg.Any<CancellationToken>()).Returns(model);
|
||||
|
||||
await _controller.GetGuide(null, null, CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelGuideData>(q => q.Start == null && q.End == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.BulkMoveToGroup(
|
||||
new BulkMoveChannelsToGroupRequest([99], "Movies"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkDelete_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.BulkDelete(
|
||||
new BulkDeleteChannelsRequest([1, 2]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.BulkDelete(
|
||||
new BulkDeleteChannelsRequest([99]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,82 @@ public class OpenApiErrorResponseContractTests
|
||||
.ShouldContain("One");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Static_OpenApi_Should_Document_Channel_List_Management_Fields()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
|
||||
|
||||
JsonElement schema = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty("ChannelResponseModel")
|
||||
.GetProperty("properties");
|
||||
|
||||
schema.TryGetProperty("group", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("isEnabled", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("sortNumber", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("categories", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Static_OpenApi_Should_Document_Channel_State_Response()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
|
||||
|
||||
JsonElement getState = document.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/api/channels/state")
|
||||
.GetProperty("get");
|
||||
|
||||
JsonElement schema = getState
|
||||
.GetProperty("responses")
|
||||
.GetProperty("200")
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema");
|
||||
|
||||
schema.GetProperty("type").GetString().ShouldBe("array");
|
||||
schema.GetProperty("items").GetProperty("$ref").GetString()
|
||||
.ShouldBe("#/components/schemas/ChannelStateResponseModel");
|
||||
|
||||
JsonElement stateProperties = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty("ChannelStateResponseModel")
|
||||
.GetProperty("properties");
|
||||
|
||||
stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue();
|
||||
stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue();
|
||||
stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue();
|
||||
stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue();
|
||||
|
||||
JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf");
|
||||
nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2);
|
||||
nowPlayingOneOf.EnumerateArray()
|
||||
.Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null")
|
||||
.ShouldBeTrue();
|
||||
nowPlayingOneOf.EnumerateArray()
|
||||
.Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) &&
|
||||
schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel")
|
||||
.ShouldBeTrue();
|
||||
|
||||
JsonElement nowPlayingProperties = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty("ChannelNowPlayingResponseModel")
|
||||
.GetProperty("properties");
|
||||
|
||||
nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue();
|
||||
nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue();
|
||||
nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue();
|
||||
|
||||
startUtc.GetProperty("type").GetString().ShouldBe("string");
|
||||
startUtc.GetProperty("format").GetString().ShouldBe("date-time");
|
||||
finishUtc.GetProperty("type").GetString().ShouldBe("string");
|
||||
finishUtc.GetProperty("format").GetString().ShouldBe("date-time");
|
||||
}
|
||||
|
||||
[TestCase("/api/channels/{id}", "get", "404")]
|
||||
[TestCase("/api/channels", "post", "404")]
|
||||
[TestCase("/api/channels", "post", "422")]
|
||||
@@ -30,6 +106,12 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/channels/{id}", "put", "422")]
|
||||
[TestCase("/api/channels/{id}", "delete", "404")]
|
||||
[TestCase("/api/channels/{id}", "delete", "422")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/collections", "post", "404")]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class StartupSpaHostingTests
|
||||
{
|
||||
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path()
|
||||
{
|
||||
StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")");
|
||||
StartupSource.ShouldContain("MapWhen(");
|
||||
StartupSource.ShouldContain("SpaStaticFileRoot");
|
||||
StartupSource.ShouldContain("RequestPath = \"/app\"");
|
||||
StartupSource.ShouldContain("appIndexFile");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback()
|
||||
{
|
||||
StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)");
|
||||
StartupSource.ShouldContain("bool IsSpaPath(PathString path)");
|
||||
}
|
||||
|
||||
private static string FindStartupPath()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
|
||||
while (directory is not null)
|
||||
{
|
||||
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,12 @@ public abstract class ChannelHandlerTestBase
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
|
||||
protected async Task SeedChannel(
|
||||
int id,
|
||||
string number,
|
||||
string name = "Test",
|
||||
int ffmpegProfileId = 1,
|
||||
string group = "ErsatzTV")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Channels.Add(
|
||||
@@ -44,7 +49,7 @@ public abstract class ChannelHandlerTestBase
|
||||
Id = id,
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Group = group,
|
||||
Categories = string.Empty,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamSelector = string.Empty,
|
||||
|
||||
@@ -22,6 +22,27 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
|
||||
|
||||
[HttpGet("/api/channels/state")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get channel runtime state")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
|
||||
|
||||
[HttpGet("/api/guide")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get the JSON channel guide (EPG)")]
|
||||
[EndpointDescription(
|
||||
"Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, " +
|
||||
"and end defaults to start plus the configured XmltvDaysToBuild window.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelGuideResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<ChannelGuideResponseModel> GetGuide(
|
||||
[FromQuery] DateTimeOffset? start,
|
||||
[FromQuery] DateTimeOffset? end,
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
@@ -88,6 +109,53 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/renumber")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Renumber channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkRenumber(
|
||||
[Required] [FromBody] BulkRenumberChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/group")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Move channels to a group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkMoveToGroup(
|
||||
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/delete")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkDelete(
|
||||
[Required] [FromBody] BulkDeleteChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Application.Channels;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record BulkRenumberChannelRequest(int Id, string Number);
|
||||
|
||||
public record BulkRenumberChannelsRequest(List<BulkRenumberChannelRequest> Channels)
|
||||
{
|
||||
public UpdateChannelNumbers ToCommand() =>
|
||||
new(Channels.Select(c => new ChannelSortViewModel { Id = c.Id, Number = c.Number }).ToList());
|
||||
}
|
||||
|
||||
public record BulkMoveChannelsToGroupRequest(List<int> ChannelIds, string Group)
|
||||
{
|
||||
public BulkMoveChannelsToGroup ToCommand() => new(ChannelIds, Group);
|
||||
}
|
||||
|
||||
public record BulkDeleteChannelsRequest(List<int> ChannelIds)
|
||||
{
|
||||
public BulkDeleteChannels ToCommand() => new(ChannelIds);
|
||||
}
|
||||
+46
-1
@@ -681,7 +681,42 @@ public class Startup
|
||||
});
|
||||
|
||||
app.MapWhen(
|
||||
ctx => !IsIptvPath(ctx.Request.Path),
|
||||
ctx => ctx.Request.Path.StartsWithSegments("/app"),
|
||||
spa =>
|
||||
{
|
||||
string spaStaticFileRoot = SpaStaticFileRoot();
|
||||
IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot)
|
||||
? new PhysicalFileProvider(spaStaticFileRoot)
|
||||
: new NullFileProvider();
|
||||
|
||||
spa.UseStaticFiles(
|
||||
new StaticFileOptions
|
||||
{
|
||||
FileProvider = spaFileProvider,
|
||||
RequestPath = "/app"
|
||||
});
|
||||
|
||||
spa.Run(
|
||||
async context =>
|
||||
{
|
||||
Microsoft.Extensions.FileProviders.IFileInfo appIndexFile =
|
||||
spaFileProvider.GetFileInfo("index.html");
|
||||
if (!appIndexFile.Exists)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.ContentLength = appIndexFile.Length;
|
||||
|
||||
await using Stream stream = appIndexFile.CreateReadStream();
|
||||
await stream.CopyToAsync(context.Response.Body);
|
||||
});
|
||||
});
|
||||
|
||||
app.MapWhen(
|
||||
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
|
||||
blazor =>
|
||||
{
|
||||
blazor.UseRouting();
|
||||
@@ -736,6 +771,16 @@ public class Startup
|
||||
path.StartsWithSegments("/lineup.json") ||
|
||||
path.StartsWithSegments("/lineup_status.json");
|
||||
}
|
||||
|
||||
bool IsSpaPath(PathString path) => path.StartsWithSegments("/app");
|
||||
|
||||
string SpaStaticFileRoot()
|
||||
{
|
||||
string webRootPath = CurrentEnvironment.WebRootPath ??
|
||||
Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot");
|
||||
|
||||
return Path.Combine(webRootPath, "app");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CustomServices(IServiceCollection services)
|
||||
|
||||
@@ -215,6 +215,94 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Get channel runtime state",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChannelStateResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChannelStateResponseModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChannelStateResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/guide": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Get the JSON channel guide (EPG)",
|
||||
"description": "Returns per-channel programme arrays for the EPG grid. When omitted, start defaults to now, and end defaults to start plus the configured XmltvDaysToBuild window.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "start",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChannelGuideResponseModel"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChannelGuideResponseModel"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ChannelGuideResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -443,6 +531,240 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/bulk/renumber": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Renumber channels",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/bulk/group": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Move channels to a group",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/bulk/delete": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Delete channels",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/{channelNumber}/playout/reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -3587,6 +3909,155 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkDeleteChannelsRequest": {
|
||||
"required": [
|
||||
"channelIds"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkMoveChannelsToGroupRequest": {
|
||||
"required": [
|
||||
"channelIds",
|
||||
"group"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkRenumberChannelRequest": {
|
||||
"required": [
|
||||
"id",
|
||||
"number"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"number": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkRenumberChannelsRequest": {
|
||||
"required": [
|
||||
"channels"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelGuideChannelResponseModel": {
|
||||
"required": [
|
||||
"number",
|
||||
"name",
|
||||
"programmes"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"programmes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChannelGuideProgrammeResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelGuideProgrammeResponseModel": {
|
||||
"required": [
|
||||
"start",
|
||||
"stop",
|
||||
"title",
|
||||
"subTitle",
|
||||
"category",
|
||||
"fillerKind"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"stop": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"subTitle": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"fillerKind": {
|
||||
"$ref": "#/components/schemas/FillerKind"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelGuideResponseModel": {
|
||||
"required": [
|
||||
"start",
|
||||
"end",
|
||||
"channels"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"channels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChannelGuideChannelResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelIdleBehavior": {
|
||||
"enum": [
|
||||
"StopOnDisconnect",
|
||||
@@ -3601,6 +4072,27 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ChannelNowPlayingResponseModel": {
|
||||
"required": [
|
||||
"title",
|
||||
"startUtc",
|
||||
"finishUtc"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"startUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"finishUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelPlayoutMode": {
|
||||
"enum": [
|
||||
"Continuous",
|
||||
@@ -3619,10 +4111,15 @@
|
||||
"required": [
|
||||
"id",
|
||||
"number",
|
||||
"sortNumber",
|
||||
"name",
|
||||
"group",
|
||||
"categories",
|
||||
"fFmpegProfile",
|
||||
"language",
|
||||
"streamingMode"
|
||||
"streamingMode",
|
||||
"isEnabled",
|
||||
"showInEpg"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3631,34 +4128,35 @@
|
||||
"format": "int32"
|
||||
},
|
||||
"number": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"sortNumber": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
"type": "string"
|
||||
},
|
||||
"categories": {
|
||||
"type": "string"
|
||||
},
|
||||
"fFmpegProfile": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"streamingMode": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"showInEpg": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3669,6 +4167,37 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ChannelStateResponseModel": {
|
||||
"required": [
|
||||
"channelId",
|
||||
"channelNumber",
|
||||
"onAir",
|
||||
"nowPlaying"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channelId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"channelNumber": {
|
||||
"type": "string"
|
||||
},
|
||||
"onAir": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ChannelNowPlayingResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelStreamSelectorMode": {
|
||||
"enum": [
|
||||
"Default",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
|
||||
</x-dc>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: chicorytv-design
|
||||
description: Use this skill to generate well-branded interfaces and assets for ChicoryTV, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Read the README.md file within this skill, and explore the other available files.
|
||||
If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand.
|
||||
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need.
|
||||
|
||||
Quick map:
|
||||
- `readme.md` — brand context, content & visual foundations, iconography, file index.
|
||||
- `styles.css` — the single entry point; link it and use the CSS custom properties.
|
||||
- `tokens/` — color, type, spacing, radius/elevation/motion tokens + font loading.
|
||||
- `components/` — React primitives (forms, data-display, feedback, navigation).
|
||||
- `templates/chicorytv-admin/` — full admin shell to start from / copy screens out of (incl. the library-to-lineup Channel Builder).
|
||||
- `assets/` — chicory-flower mark, wordmark, app icon.
|
||||
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"plugins": [
|
||||
"react",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"react/forbid-elements": [
|
||||
"warn",
|
||||
{
|
||||
"forbid": []
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"components/data-display/**",
|
||||
"components/feedback/**",
|
||||
"components/forms/**",
|
||||
"components/navigation/**",
|
||||
"design_handoff_channel_builder/**"
|
||||
],
|
||||
"message": "Import design-system components from 'index.js', not component internals."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
|
||||
"message": "Raw hex color — use a design-system color token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/\\b\\d+px\\b/]",
|
||||
"message": "Raw px value — use a design-system spacing token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Geist|Geist Mono))/i]",
|
||||
"message": "Font not provided by the design system. Available: Geist, Geist Mono."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|solid|dot|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Badge> doesn't accept that prop. Declared props: children, tone, solid, dot, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent|ok|warn|error)$/]",
|
||||
"message": "<Badge> tone must be one of 'neutral' | 'accent' | 'ok' | 'warn' | 'error'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|startIcon|endIcon|disabled|loading|fullWidth|type|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Button> doesn't accept that prop. Declared props: children, variant, size, startIcon, endIcon, disabled, loading, fullWidth, type, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|danger)$/]",
|
||||
"message": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<Button> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit|reset)$/]",
|
||||
"message": "<Button> type must be one of 'button' | 'submit' | 'reset'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|subtitle|actions|children|padded|inset|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Card> doesn't accept that prop. Declared props: title, subtitle, actions, children, padded, inset, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='ChannelLogo'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|src|size|radius|style|key|ref|className|style|children)$/]",
|
||||
"message": "<ChannelLogo> doesn't accept that prop. Declared props: name, src, size, radius, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|indeterminate|disabled|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Checkbox> doesn't accept that prop. Declared props: checked, onChange, label, indeterminate, disabled, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|variant|active|disabled|title|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<IconButton> doesn't accept that prop. Declared props: children, size, variant, active, disabled, title, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<IconButton> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:ghost|solid)$/]",
|
||||
"message": "<IconButton> variant must be one of 'ghost' | 'solid'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|onChange|placeholder|leadingIcon|trailing|error|disabled|size|type|fullWidth|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Input> doesn't accept that prop. Declared props: label, value, onChange, placeholder, leadingIcon, trailing, error, disabled, size, type, fullWidth, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<Input> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='NavItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|label|active|badge|badgeTone|href|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<NavItem> doesn't accept that prop. Declared props: icon, label, active, badge, badgeTone, href, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='NavItem'] > JSXAttribute[name.name='badgeTone'] > Literal[value!=/^(?:warn|error|accent)$/]",
|
||||
"message": "<NavItem> badgeTone must be one of 'warn' | 'error' | 'accent'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='ProgressBar'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|tone|height|showLabel|style|key|ref|className|style|children)$/]",
|
||||
"message": "<ProgressBar> doesn't accept that prop. Declared props: value, tone, height, showLabel, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='ProgressBar'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|ok|warn|error)$/]",
|
||||
"message": "<ProgressBar> tone must be one of 'accent' | 'ok' | 'warn' | 'error'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='SelectOption'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|label|key|ref|className|style|children)$/]",
|
||||
"message": "<SelectOption> doesn't accept that prop. Declared props: value, label."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Spinner'] > JSXAttribute > JSXIdentifier[name!=/^(?:size|tone|stroke|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Spinner> doesn't accept that prop. Declared props: size, tone, stroke, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Spinner'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|muted|ok)$/]",
|
||||
"message": "<Spinner> tone must be one of 'accent' | 'muted' | 'ok'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Stat'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|unit|delta|deltaTone|icon|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Stat> doesn't accept that prop. Declared props: label, value, unit, delta, deltaTone, icon, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Stat'] > JSXAttribute[name.name='deltaTone'] > Literal[value!=/^(?:up|down|neutral)$/]",
|
||||
"message": "<Stat> deltaTone must be one of 'up' | 'down' | 'neutral'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='StatusDot'] > JSXAttribute > JSXIdentifier[name!=/^(?:status|pulse|size|label|style|key|ref|className|style|children)$/]",
|
||||
"message": "<StatusDot> doesn't accept that prop. Declared props: status, pulse, size, label, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='StatusDot'] > JSXAttribute[name.name='status'] > Literal[value!=/^(?:live|ok|warn|error|idle)$/]",
|
||||
"message": "<StatusDot> status must be one of 'live' | 'ok' | 'warn' | 'error' | 'idle'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|disabled|size|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Switch> doesn't accept that prop. Declared props: checked, onChange, label, disabled, size, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<Switch> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='TabItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|label|icon|count|key|ref|className|style|children)$/]",
|
||||
"message": "<TabItem> doesn't accept that prop. Declared props: value, label, icon, count."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onRemove|icon|tone|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tag> doesn't accept that prop. Declared props: children, onRemove, icon, tone, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent)$/]",
|
||||
"message": "<Tag> tone must be one of 'neutral' | 'accent'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|title|message|onClose|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Toast> doesn't accept that prop. Declared props: tone, title, message, onClose, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:info|ok|warn|error)$/]",
|
||||
"message": "<Toast> tone must be one of 'info' | 'ok' | 'warn' | 'error'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|placement|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tooltip> doesn't accept that prop. Declared props: label, placement, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute[name.name='placement'] > Literal[value!=/^(?:top|bottom|left|right)$/]",
|
||||
"message": "<Tooltip> placement must be one of 'top' | 'bottom' | 'left' | 'right'."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/index.js"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-omelette": {
|
||||
"components": {
|
||||
"Badge": {
|
||||
"replaces": []
|
||||
},
|
||||
"Button": {
|
||||
"replaces": []
|
||||
},
|
||||
"Card": {
|
||||
"replaces": []
|
||||
},
|
||||
"ChannelLogo": {
|
||||
"replaces": []
|
||||
},
|
||||
"Checkbox": {
|
||||
"replaces": []
|
||||
},
|
||||
"IconButton": {
|
||||
"replaces": []
|
||||
},
|
||||
"Input": {
|
||||
"replaces": []
|
||||
},
|
||||
"NavItem": {
|
||||
"replaces": []
|
||||
},
|
||||
"ProgressBar": {
|
||||
"replaces": []
|
||||
},
|
||||
"SelectOption": {
|
||||
"replaces": []
|
||||
},
|
||||
"Spinner": {
|
||||
"replaces": []
|
||||
},
|
||||
"Stat": {
|
||||
"replaces": []
|
||||
},
|
||||
"StatusDot": {
|
||||
"replaces": []
|
||||
},
|
||||
"Switch": {
|
||||
"replaces": []
|
||||
},
|
||||
"TabItem": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tag": {
|
||||
"replaces": []
|
||||
},
|
||||
"Toast": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tooltip": {
|
||||
"replaces": []
|
||||
}
|
||||
},
|
||||
"tokens": [
|
||||
"--action-primary",
|
||||
"--action-primary-hover",
|
||||
"--action-primary-press",
|
||||
"--border-control",
|
||||
"--border-hairline",
|
||||
"--control-h",
|
||||
"--control-h-sm",
|
||||
"--ctv-accent",
|
||||
"--ctv-accent-hover",
|
||||
"--ctv-accent-press",
|
||||
"--ctv-accent-ring",
|
||||
"--ctv-accent-soft",
|
||||
"--ctv-bg",
|
||||
"--ctv-bg-sunken",
|
||||
"--ctv-border",
|
||||
"--ctv-border-strong",
|
||||
"--ctv-error",
|
||||
"--ctv-error-soft",
|
||||
"--ctv-live",
|
||||
"--ctv-live-soft",
|
||||
"--ctv-ok",
|
||||
"--ctv-ok-soft",
|
||||
"--ctv-secondary",
|
||||
"--ctv-secondary-soft",
|
||||
"--ctv-surface",
|
||||
"--ctv-surface-2",
|
||||
"--ctv-surface-3",
|
||||
"--ctv-text",
|
||||
"--ctv-text-faint",
|
||||
"--ctv-text-muted",
|
||||
"--ctv-warn",
|
||||
"--ctv-warn-soft",
|
||||
"--dur-base",
|
||||
"--dur-fast",
|
||||
"--dur-slow",
|
||||
"--ease-out",
|
||||
"--ease-standard",
|
||||
"--focus-ring",
|
||||
"--font-mono",
|
||||
"--font-sans",
|
||||
"--gap-inline",
|
||||
"--leading-normal",
|
||||
"--leading-snug",
|
||||
"--leading-tight",
|
||||
"--numeric-feature",
|
||||
"--pad-card",
|
||||
"--pad-cell-x",
|
||||
"--pad-cell-y",
|
||||
"--pad-control-x",
|
||||
"--pad-control-y",
|
||||
"--pad-page",
|
||||
"--radius-lg",
|
||||
"--radius-md",
|
||||
"--radius-pill",
|
||||
"--radius-sm",
|
||||
"--radius-xs",
|
||||
"--ring-focus",
|
||||
"--row-h",
|
||||
"--shadow-lg",
|
||||
"--shadow-md",
|
||||
"--shadow-none",
|
||||
"--shadow-pop",
|
||||
"--shadow-sm",
|
||||
"--sidebar-w",
|
||||
"--space-0",
|
||||
"--space-1",
|
||||
"--space-10",
|
||||
"--space-11",
|
||||
"--space-12",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--space-4",
|
||||
"--space-5",
|
||||
"--space-6",
|
||||
"--space-7",
|
||||
"--space-8",
|
||||
"--space-9",
|
||||
"--status-error",
|
||||
"--status-live",
|
||||
"--status-ok",
|
||||
"--status-warn",
|
||||
"--surface-app",
|
||||
"--surface-card",
|
||||
"--surface-raised",
|
||||
"--surface-selected",
|
||||
"--text-2xl",
|
||||
"--text-2xs",
|
||||
"--text-3xl",
|
||||
"--text-disabled",
|
||||
"--text-lg",
|
||||
"--text-md",
|
||||
"--text-on-accent",
|
||||
"--text-primary",
|
||||
"--text-secondary",
|
||||
"--text-sm",
|
||||
"--text-xl",
|
||||
"--text-xs",
|
||||
"--topbar-h",
|
||||
"--tracking-caps",
|
||||
"--tracking-normal",
|
||||
"--tracking-tight",
|
||||
"--tracking-wide",
|
||||
"--weight-bold",
|
||||
"--weight-medium",
|
||||
"--weight-normal",
|
||||
"--weight-semibold"
|
||||
],
|
||||
"tokenKinds": {
|
||||
"--ctv-bg": "color",
|
||||
"--ctv-bg-sunken": "color",
|
||||
"--ctv-surface": "color",
|
||||
"--ctv-surface-2": "color",
|
||||
"--ctv-surface-3": "color",
|
||||
"--ctv-border": "color",
|
||||
"--ctv-border-strong": "color",
|
||||
"--ctv-text": "font",
|
||||
"--ctv-text-muted": "font",
|
||||
"--ctv-text-faint": "font",
|
||||
"--ctv-accent": "color",
|
||||
"--ctv-accent-hover": "color",
|
||||
"--ctv-accent-press": "color",
|
||||
"--ctv-accent-soft": "color",
|
||||
"--ctv-accent-ring": "color",
|
||||
"--ctv-secondary": "color",
|
||||
"--ctv-secondary-soft": "color",
|
||||
"--ctv-ok": "color",
|
||||
"--ctv-ok-soft": "color",
|
||||
"--ctv-warn": "color",
|
||||
"--ctv-warn-soft": "color",
|
||||
"--ctv-error": "color",
|
||||
"--ctv-error-soft": "color",
|
||||
"--ctv-live": "color",
|
||||
"--ctv-live-soft": "color",
|
||||
"--surface-app": "color",
|
||||
"--surface-card": "color",
|
||||
"--surface-raised": "color",
|
||||
"--surface-selected": "color",
|
||||
"--border-hairline": "color",
|
||||
"--border-control": "color",
|
||||
"--text-primary": "font",
|
||||
"--text-secondary": "font",
|
||||
"--text-disabled": "font",
|
||||
"--text-on-accent": "font",
|
||||
"--action-primary": "color",
|
||||
"--action-primary-hover": "color",
|
||||
"--action-primary-press": "color",
|
||||
"--focus-ring": "color",
|
||||
"--status-ok": "color",
|
||||
"--status-warn": "color",
|
||||
"--status-error": "color",
|
||||
"--status-live": "color",
|
||||
"--font-sans": "font",
|
||||
"--font-mono": "font",
|
||||
"--text-2xs": "font",
|
||||
"--text-xs": "font",
|
||||
"--text-sm": "font",
|
||||
"--text-md": "font",
|
||||
"--text-lg": "font",
|
||||
"--text-xl": "font",
|
||||
"--text-2xl": "font",
|
||||
"--text-3xl": "font",
|
||||
"--weight-normal": "font",
|
||||
"--weight-medium": "font",
|
||||
"--weight-semibold": "font",
|
||||
"--weight-bold": "font",
|
||||
"--leading-tight": "font",
|
||||
"--leading-snug": "font",
|
||||
"--leading-normal": "font",
|
||||
"--tracking-tight": "font",
|
||||
"--tracking-normal": "font",
|
||||
"--tracking-wide": "font",
|
||||
"--tracking-caps": "font",
|
||||
"--numeric-feature": "other",
|
||||
"--space-0": "spacing",
|
||||
"--space-1": "spacing",
|
||||
"--space-2": "spacing",
|
||||
"--space-3": "spacing",
|
||||
"--space-4": "spacing",
|
||||
"--space-5": "spacing",
|
||||
"--space-6": "spacing",
|
||||
"--space-7": "spacing",
|
||||
"--space-8": "spacing",
|
||||
"--space-9": "spacing",
|
||||
"--space-10": "spacing",
|
||||
"--space-11": "spacing",
|
||||
"--space-12": "spacing",
|
||||
"--gap-inline": "spacing",
|
||||
"--pad-control-x": "spacing",
|
||||
"--pad-control-y": "spacing",
|
||||
"--pad-card": "spacing",
|
||||
"--pad-cell-x": "spacing",
|
||||
"--pad-cell-y": "spacing",
|
||||
"--pad-page": "spacing",
|
||||
"--sidebar-w": "spacing",
|
||||
"--topbar-h": "spacing",
|
||||
"--row-h": "spacing",
|
||||
"--control-h": "spacing",
|
||||
"--control-h-sm": "spacing",
|
||||
"--radius-xs": "radius",
|
||||
"--radius-sm": "radius",
|
||||
"--radius-md": "radius",
|
||||
"--radius-lg": "radius",
|
||||
"--radius-pill": "radius",
|
||||
"--shadow-none": "shadow",
|
||||
"--shadow-sm": "shadow",
|
||||
"--shadow-md": "shadow",
|
||||
"--shadow-lg": "shadow",
|
||||
"--shadow-pop": "shadow",
|
||||
"--ring-focus": "color",
|
||||
"--ease-standard": "other",
|
||||
"--ease-out": "other",
|
||||
"--dur-fast": "other",
|
||||
"--dur-base": "other",
|
||||
"--dur-slow": "other"
|
||||
},
|
||||
"fontFamilies": [
|
||||
"Geist",
|
||||
"Geist Mono"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<g transform="translate(32 32)">
|
||||
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
|
||||
<circle r="8.5" fill="#0F1115"></circle>
|
||||
<circle r="5" fill="#5B7CFA"></circle>
|
||||
<circle r="2" fill="#0F1115"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#171A21"></rect>
|
||||
<g transform="translate(32 32) scale(0.82)">
|
||||
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
|
||||
<circle r="8.5" fill="#171A21"></circle>
|
||||
<circle r="5" fill="#5B7CFA"></circle>
|
||||
<circle r="2" fill="#171A21"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 48" width="250" height="48" fill="none">
|
||||
<g transform="translate(24 24) scale(0.62)">
|
||||
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
|
||||
<circle r="8.5" fill="#0F1115"></circle>
|
||||
<circle r="5" fill="#5B7CFA"></circle>
|
||||
<circle r="2" fill="#0F1115"></circle>
|
||||
</g>
|
||||
<text x="52" y="32" font-family="Geist, system-ui, sans-serif" font-size="24" font-weight="600" letter-spacing="-0.5" fill="#E6E9EF">Chicory<tspan fill="#5B7CFA">TV</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface BadgeProps {
|
||||
children?: React.ReactNode;
|
||||
/** Semantic color. `accent` doubles as the live/on-air tone. */
|
||||
tone?: "neutral" | "accent" | "ok" | "warn" | "error";
|
||||
/** Filled instead of tinted-soft. */
|
||||
solid?: boolean;
|
||||
/** Leading status dot. */
|
||||
dot?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact status/label badge — streaming mode, HLS/MPEG-TS, health status.
|
||||
*/
|
||||
export function Badge(props: BadgeProps): JSX.Element;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
const tones = {
|
||||
neutral: { fg: "var(--text-secondary)", bg: "var(--ctv-surface-3)", bd: "var(--border-hairline)" },
|
||||
accent: { fg: "var(--ctv-accent)", bg: "var(--ctv-accent-soft)", bd: "rgba(91,124,250,0.35)" },
|
||||
ok: { fg: "var(--status-ok)", bg: "var(--ctv-ok-soft)", bd: "rgba(63,185,132,0.35)" },
|
||||
warn: { fg: "var(--status-warn)", bg: "var(--ctv-warn-soft)", bd: "rgba(224,168,61,0.35)" },
|
||||
error: { fg: "var(--status-error)", bg: "var(--ctv-error-soft)", bd: "rgba(229,72,77,0.35)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Small status/label badge. Soft (tinted) or solid.
|
||||
*/
|
||||
export function Badge({ children, tone = "neutral", solid = false, dot = false, style = {} }) {
|
||||
const t = tones[tone] || tones.neutral;
|
||||
const solidBg = { neutral: "var(--ctv-surface-3)", accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" };
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
height: "20px",
|
||||
padding: dot ? "0 8px 0 6px" : "0 8px",
|
||||
borderRadius: "var(--radius-xs)",
|
||||
font: `var(--weight-medium) var(--text-2xs)/1 var(--font-sans)`,
|
||||
letterSpacing: "0.02em",
|
||||
color: solid ? (tone === "warn" ? "#1A1206" : "#fff") : t.fg,
|
||||
background: solid ? solidBg[tone] : t.bg,
|
||||
border: solid ? "1px solid transparent" : `1px solid ${t.bd}`,
|
||||
whiteSpace: "nowrap",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: solid ? "rgba(255,255,255,0.9)" : t.fg }} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
**Badge** — small status/label pill. Soft (tinted) by default; `solid` for stronger emphasis. Tones map to the status system; `accent` is also the live/on-air tone.
|
||||
|
||||
```jsx
|
||||
<Badge tone="accent" dot>Live</Badge>
|
||||
<Badge tone="ok">Healthy</Badge>
|
||||
<Badge tone="warn">Not normalizing</Badge>
|
||||
<Badge tone="neutral">HLS Segmenter</Badge>
|
||||
<Badge tone="error" solid>Error</Badge>
|
||||
```
|
||||
|
||||
Props: `tone` (neutral·accent·ok·warn·error), `solid`, `dot`.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* @startingPoint section="Layout" subtitle="Card surface with header + actions" viewport="700x260"
|
||||
*/
|
||||
export interface CardProps {
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
/** Right-aligned header actions (buttons, menu). */
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** Body padding — turn off for flush tables/lists. */
|
||||
padded?: boolean;
|
||||
/** Sunken well background instead of card surface. */
|
||||
inset?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel surface with optional header row; the base container for dashboard
|
||||
* widgets, forms and detail panes.
|
||||
*/
|
||||
export function Card(props: CardProps): JSX.Element;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Surface container. Optional header (title + actions) and body padding.
|
||||
*/
|
||||
export function Card({ title, subtitle, actions = null, children, padded = true, inset = false, style = {} }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: inset ? "var(--ctv-bg-sunken)" : "var(--surface-card)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
overflow: "hidden",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
padding: "13px 16px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{title && <div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>}
|
||||
{subtitle && <div style={{ marginTop: 3, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div style={{ display: "flex", alignItems: "center", gap: "8px", flex: "0 0 auto" }}>{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: padded ? "16px" : 0 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
**Card** — the panel surface behind dashboard widgets, forms and detail panes. Header row is optional; set `padded={false}` when the body is a flush table or list.
|
||||
|
||||
```jsx
|
||||
<Card title="Recent activity" actions={<Button size="sm" variant="ghost">View all</Button>}>
|
||||
…
|
||||
</Card>
|
||||
<Card padded={false}><ChannelTable/></Card>
|
||||
```
|
||||
|
||||
Props: `title`, `subtitle`, `actions`, `padded`, `inset`.
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ChannelLogoProps {
|
||||
/** Channel name — seeds the generated initials + color when no image. */
|
||||
name?: string;
|
||||
/** Logo image URL; falls back to a generated chip when absent. */
|
||||
src?: string | null;
|
||||
size?: number;
|
||||
radius?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square channel logo; shows the uploaded image or a deterministic
|
||||
* initials chip keyed to the channel name (like ErsatzTV's generated logos).
|
||||
*/
|
||||
export function ChannelLogo(props: ChannelLogoProps): JSX.Element;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
|
||||
const palette = [
|
||||
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
|
||||
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
|
||||
];
|
||||
|
||||
function hashIndex(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
return h % palette.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel logo tile. Renders an image when given, else a generated
|
||||
* initials chip keyed to the channel name — mirrors ErsatzTV's logo/gen.
|
||||
*/
|
||||
export function ChannelLogo({ name = "", src = null, size = 40, radius = "var(--radius-sm)", style = {} }) {
|
||||
const [fg, bg] = palette[hashIndex(name)];
|
||||
const initials = name
|
||||
.split(/[\s\-|:]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size,
|
||||
height: size,
|
||||
flex: "0 0 auto",
|
||||
overflow: "hidden",
|
||||
borderRadius: radius,
|
||||
background: src ? "var(--ctv-bg-sunken)" : bg,
|
||||
border: "1px solid var(--border-hairline)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={name} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
||||
) : (
|
||||
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.34)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{initials}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**ChannelLogo** — square channel avatar. Given `src`, shows the logo; otherwise generates a stable initials chip + color from `name` (mirrors ErsatzTV's `iptv/logos/gen`).
|
||||
|
||||
```jsx
|
||||
<ChannelLogo name="Retro Cartoons" size={40} />
|
||||
<ChannelLogo name="News 24" src="/logos/news24.png" />
|
||||
```
|
||||
|
||||
Props: `name`, `src`, `size`, `radius`.
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** 0–100. Pass null/undefined for an indeterminate sweep. */
|
||||
value?: number | null;
|
||||
tone?: "accent" | "ok" | "warn" | "error";
|
||||
height?: number;
|
||||
/** Append a mono percentage on the right. */
|
||||
showLabel?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim progress bar for library scans, playout builds and transcodes.
|
||||
*/
|
||||
export function ProgressBar(props: ProgressBarProps): JSX.Element;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
|
||||
const toneColor = {
|
||||
accent: "var(--action-primary)",
|
||||
ok: "var(--status-ok)",
|
||||
warn: "var(--status-warn)",
|
||||
error: "var(--status-error)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin determinate/indeterminate progress bar — library scans, transcodes.
|
||||
*/
|
||||
export function ProgressBar({ value = null, tone = "accent", height = 6, showLabel = false, style = {} }) {
|
||||
const c = toneColor[tone] || toneColor.accent;
|
||||
const indeterminate = value == null;
|
||||
const pct = Math.max(0, Math.min(100, value ?? 0));
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px", ...style }}>
|
||||
<div style={{ position: "relative", flex: 1, height, borderRadius: "var(--radius-pill)", background: "var(--ctv-surface-3)", overflow: "hidden" }}>
|
||||
<style>{`@keyframes ctv-indet{0%{left:-40%}100%{left:100%}}`}</style>
|
||||
{indeterminate ? (
|
||||
<span style={{ position: "absolute", top: 0, bottom: 0, width: "40%", borderRadius: "inherit", background: c, animation: "ctv-indet 1.1s var(--ease-standard) infinite" }} />
|
||||
) : (
|
||||
<span style={{ position: "absolute", top: 0, left: 0, bottom: 0, width: `${pct}%`, borderRadius: "inherit", background: c, transition: "width var(--dur-slow) var(--ease-out)" }} />
|
||||
)}
|
||||
</div>
|
||||
{showLabel && !indeterminate && (
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", minWidth: 34, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{Math.round(pct)}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**ProgressBar** — slim bar for library scans, playout builds and transcodes. Omit `value` for an indeterminate sweep (queued/starting work).
|
||||
|
||||
```jsx
|
||||
<ProgressBar value={62} showLabel />
|
||||
<ProgressBar value={null} tone="accent" /> {/* scanning… */}
|
||||
```
|
||||
|
||||
Props: `value` (0–100 or null), `tone` (accent·ok·warn·error), `height`, `showLabel`.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StatProps {
|
||||
label: string;
|
||||
/** The metric value — rendered in tabular mono. */
|
||||
value: React.ReactNode;
|
||||
unit?: string;
|
||||
/** Small change indicator, e.g. "+3" or "2 warnings". */
|
||||
delta?: React.ReactNode;
|
||||
deltaTone?: "up" | "down" | "neutral";
|
||||
icon?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPI — mono tabular value with label, optional delta and icon.
|
||||
*/
|
||||
export function Stat(props: StatProps): JSX.Element;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Dashboard metric. Big mono value, label, optional delta + icon.
|
||||
*/
|
||||
export function Stat({ label, value, unit, delta = null, deltaTone = "neutral", icon = null, style = {} }) {
|
||||
const dc = { up: "var(--status-ok)", down: "var(--status-error)", neutral: "var(--text-secondary)" };
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", ...style }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "7px" }}>
|
||||
{icon && <span style={{ display: "inline-flex", color: "var(--text-disabled)" }}>{icon}</span>}
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.03em", color: "var(--text-secondary)" }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: "6px" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-2xl)/1 var(--font-mono)", color: "var(--text-primary)", letterSpacing: "-0.01em", fontVariantNumeric: "tabular-nums" }}>{value}</span>
|
||||
{unit && <span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>}
|
||||
{delta != null && (
|
||||
<span style={{ marginLeft: 2, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: dc[deltaTone] }}>{delta}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
**Stat** — a dashboard KPI. Value is set in tabular mono so columns of numbers align. Use for channel counts, active streams, uptime.
|
||||
|
||||
```jsx
|
||||
<Stat label="Active streams" value="7" icon={<Ico n="Radio"/>} delta="+2" deltaTone="up" />
|
||||
<Stat label="Channels" value="128" />
|
||||
<Stat label="Uptime" value="14d 06h" />
|
||||
```
|
||||
|
||||
Props: `label`, `value`, `unit`, `delta`, `deltaTone` (up·down·neutral), `icon`.
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StatusDotProps {
|
||||
/** `live` auto-pulses in accent; others are static. */
|
||||
status?: "live" | "ok" | "warn" | "error" | "idle";
|
||||
/** Force the pulsing ring on any status. */
|
||||
pulse?: boolean;
|
||||
size?: number;
|
||||
label?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small colored status dot; pulses for live/on-air channels.
|
||||
*/
|
||||
export function StatusDot(props: StatusDotProps): JSX.Element;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
const colors = {
|
||||
live: "var(--status-live)",
|
||||
ok: "var(--status-ok)",
|
||||
warn: "var(--status-warn)",
|
||||
error: "var(--status-error)",
|
||||
idle: "var(--text-disabled)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Status dot with optional pulsing ring — on-air / live indicators.
|
||||
*/
|
||||
export function StatusDot({ status = "idle", pulse = false, size = 8, label, style = {} }) {
|
||||
const c = colors[status] || colors.idle;
|
||||
const doPulse = pulse || status === "live";
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "7px", ...style }}>
|
||||
<span style={{ position: "relative", width: size, height: size, flex: "0 0 auto" }}>
|
||||
{doPulse && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
borderRadius: "50%",
|
||||
background: c,
|
||||
animation: "ctv-ping 1.6s cubic-bezier(0,0,0.2,1) infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span style={{ position: "absolute", inset: 0, borderRadius: "50%", background: c, boxShadow: doPulse ? `0 0 6px ${c}` : "none" }} />
|
||||
<style>{`@keyframes ctv-ping{0%{transform:scale(1);opacity:.55}70%,100%{transform:scale(2.4);opacity:0}}`}</style>
|
||||
</span>
|
||||
{label && <span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
**StatusDot** — tiny colored dot for channel/transcode state. `live` pulses in accent (on-air), the rest are static status colors.
|
||||
|
||||
```jsx
|
||||
<StatusDot status="live" label="On air" />
|
||||
<StatusDot status="ok" label="Idle" />
|
||||
<StatusDot status="error" label="Failed" />
|
||||
```
|
||||
|
||||
Props: `status` (live·ok·warn·error·idle), `pulse`, `size`, `label`.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TagProps {
|
||||
children?: React.ReactNode;
|
||||
/** Show a remove (×) button and handle its click. */
|
||||
onRemove?: () => void;
|
||||
/** Optional leading icon. */
|
||||
icon?: React.ReactNode;
|
||||
tone?: "neutral" | "accent";
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip for filters, smart-collection criteria and media tags; optionally removable.
|
||||
*/
|
||||
export function Tag(props: TagProps): JSX.Element;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Removable chip/tag — smart-collection filters, tags on media.
|
||||
*/
|
||||
export function Tag({ children, onRemove, icon = null, tone = "neutral", style = {} }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const tint = tone === "accent";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
height: "22px",
|
||||
padding: onRemove ? "0 5px 0 8px" : "0 9px",
|
||||
borderRadius: "var(--radius-xs)",
|
||||
font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)",
|
||||
color: tint ? "var(--ctv-accent)" : "var(--text-primary)",
|
||||
background: tint ? "var(--ctv-accent-soft)" : "var(--ctv-surface-3)",
|
||||
border: `1px solid ${tint ? "rgba(91,124,250,0.35)" : "var(--border-hairline)"}`,
|
||||
whiteSpace: "nowrap",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{icon && <span style={{ display: "inline-flex", color: "var(--text-disabled)" }}>{icon}</span>}
|
||||
{children}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
aria-label="Remove"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 15,
|
||||
height: 15,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
cursor: "pointer",
|
||||
color: hover ? "var(--text-primary)" : "var(--text-disabled)",
|
||||
background: hover ? "rgba(255,255,255,0.08)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**Tag** — chip for filters, smart-collection criteria, and media tags. Pass `onRemove` to show a × button.
|
||||
|
||||
```jsx
|
||||
<Tag>genre: comedy</Tag>
|
||||
<Tag tone="accent" onRemove={() => drop(i)}>released ≥ 1990</Tag>
|
||||
```
|
||||
|
||||
Props: `onRemove`, `icon`, `tone` (neutral·accent).
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- @dsCard group="Components" viewport="700x330" name="Data display" subtitle="Badge, StatusDot, Tag, ChannelLogo, Stat, ProgressBar, Card" -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../../styles.css">
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="../../_ds_bundle.js"></script>
|
||||
<style>
|
||||
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
|
||||
#root { padding: 22px 24px; }
|
||||
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Badge, StatusDot, Tag, ChannelLogo, Stat, ProgressBar, Card } = NS;
|
||||
const Ico = ({ n, s = 15 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current) { r.current.innerHTML=""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el);} }); return <span ref={r} style={{ display: "inline-flex" }} />; };
|
||||
|
||||
function Demo() {
|
||||
return (
|
||||
<div>
|
||||
<div className="row">
|
||||
<Badge tone="accent" dot>Live</Badge>
|
||||
<Badge tone="ok">Healthy</Badge>
|
||||
<Badge tone="warn">Not normalizing</Badge>
|
||||
<Badge tone="error" solid>Error</Badge>
|
||||
<Badge tone="neutral">HLS Segmenter</Badge>
|
||||
<span style={{ width: 8 }} />
|
||||
<StatusDot status="live" label="On air" />
|
||||
<StatusDot status="ok" label="Idle" />
|
||||
<StatusDot status="error" label="Failed" />
|
||||
</div>
|
||||
<div className="row">
|
||||
<Tag icon={<Ico n="Tag" s={12} />}>genre: comedy</Tag>
|
||||
<Tag tone="accent" onRemove={() => {}}>released ≥ 1990</Tag>
|
||||
<Tag onRemove={() => {}}>rating: PG</Tag>
|
||||
<span style={{ width: 8 }} />
|
||||
<ChannelLogo name="Retro Cartoons" size={38} />
|
||||
<ChannelLogo name="News 24" size={38} />
|
||||
<ChannelLogo name="Late Night" size={38} />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.1fr 1fr", gap: 16, alignItems: "start" }}>
|
||||
<Card padded={true} style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
|
||||
<Stat label="Active streams" value="7" icon={<Ico n="Radio" />} delta="+2" deltaTone="up" />
|
||||
<Stat label="Channels" value="128" icon={<Ico n="Tv" />} />
|
||||
<Stat label="Scheduled" value="1,204" icon={<Ico n="CalendarClock" />} />
|
||||
<Stat label="Uptime" value="14d 06h" icon={<Ico n="Activity" />} />
|
||||
</Card>
|
||||
<Card title="Library scan" subtitle="Movies · local">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<ProgressBar value={62} showLabel />
|
||||
<ProgressBar value={null} tone="accent" />
|
||||
<ProgressBar value={100} tone="ok" showLabel />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: number;
|
||||
tone?: "accent" | "muted" | "ok";
|
||||
stroke?: number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indeterminate ring spinner — inline busy state for locked playouts/rows.
|
||||
*/
|
||||
export function Spinner(props: SpinnerProps): JSX.Element;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Indeterminate ring spinner.
|
||||
*/
|
||||
export function Spinner({ size = 18, tone = "accent", stroke = 2.4, style = {} }) {
|
||||
const c = { accent: "var(--action-primary)", muted: "var(--text-disabled)", ok: "var(--status-ok)" }[tone] || "var(--action-primary)";
|
||||
return (
|
||||
<span style={{ display: "inline-flex", ...style }}>
|
||||
<style>{`@keyframes ctv-spin{to{transform:rotate(360deg)}}`}</style>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ animation: "ctv-spin 0.7s linear infinite" }}>
|
||||
<circle cx="12" cy="12" r="9" stroke={c} strokeOpacity="0.2" strokeWidth={stroke} />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke={c} strokeWidth={stroke} strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
**Spinner** — inline busy indicator for locked playouts, in-flight row actions, and loading panes.
|
||||
|
||||
```jsx
|
||||
<Spinner size={16} tone="accent" />
|
||||
```
|
||||
|
||||
Props: `size`, `tone` (accent·muted·ok), `stroke`.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ToastProps {
|
||||
tone?: "info" | "ok" | "warn" | "error";
|
||||
title?: React.ReactNode;
|
||||
message?: React.ReactNode;
|
||||
/** Show a dismiss button. */
|
||||
onClose?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast / inline alert with a tone-colored accent edge — save confirmations,
|
||||
* scan results, playout-build failures.
|
||||
*/
|
||||
export function Toast(props: ToastProps): JSX.Element;
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
const map = {
|
||||
ok: { c: "var(--status-ok)", bg: "var(--ctv-ok-soft)", icon: "M5 12.5l4.5 4.5L19 7" },
|
||||
warn: { c: "var(--status-warn)", bg: "var(--ctv-warn-soft)", icon: "M12 8v5M12 17h.01" },
|
||||
error: { c: "var(--status-error)", bg: "var(--ctv-error-soft)", icon: "M12 8v5M12 17h.01" },
|
||||
info: { c: "var(--ctv-accent)", bg: "var(--ctv-accent-soft)", icon: "M12 11v5M12 8h.01" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast / inline alert. Icon, message, optional close.
|
||||
*/
|
||||
export function Toast({ tone = "info", title, message, onClose, style = {} }) {
|
||||
const t = map[tone] || map.info;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "11px",
|
||||
width: "min(380px, 100%)",
|
||||
padding: "12px 13px",
|
||||
background: "var(--surface-raised)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
borderLeft: `2px solid ${t.c}`,
|
||||
borderRadius: "var(--radius-md)",
|
||||
boxShadow: "var(--shadow-md)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 22, height: 22, borderRadius: "var(--radius-xs)", background: t.bg, flex: "0 0 auto", marginTop: 1 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke={t.c} strokeWidth="0" fill="none" /><path d={t.icon} stroke={t.c} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && <div style={{ font: "var(--weight-semibold) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>}
|
||||
{message && <div style={{ marginTop: title ? 2 : 0, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{message}</div>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose} aria-label="Dismiss" style={{ display: "inline-flex", padding: 3, border: "none", background: "transparent", color: "var(--text-disabled)", cursor: "pointer", flex: "0 0 auto" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**Toast** — transient notification or inline alert with a tone-colored left edge. Use for save confirmations, scan results, and playout-build failures.
|
||||
|
||||
```jsx
|
||||
<Toast tone="ok" title="Channel saved" message="Retro Cartoons is now on air." onClose={dismiss} />
|
||||
<Toast tone="error" title="Playout build failed" message="No items match the schedule." />
|
||||
```
|
||||
|
||||
Props: `tone` (info·ok·warn·error), `title`, `message`, `onClose`.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TooltipProps {
|
||||
label: React.ReactNode;
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
children: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover tooltip wrapping any trigger — icon-button labels, truncated text.
|
||||
*/
|
||||
export function Tooltip(props: TooltipProps): JSX.Element;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Hover tooltip. Wraps a trigger; shows a small dark label.
|
||||
*/
|
||||
export function Tooltip({ label, placement = "top", children, style = {} }) {
|
||||
const [show, setShow] = React.useState(false);
|
||||
const pos = {
|
||||
top: { bottom: "calc(100% + 7px)", left: "50%", transform: "translateX(-50%)" },
|
||||
bottom: { top: "calc(100% + 7px)", left: "50%", transform: "translateX(-50%)" },
|
||||
left: { right: "calc(100% + 7px)", top: "50%", transform: "translateY(-50%)" },
|
||||
right: { left: "calc(100% + 7px)", top: "50%", transform: "translateY(-50%)" },
|
||||
}[placement];
|
||||
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
style={{ position: "relative", display: "inline-flex", ...style }}
|
||||
>
|
||||
{children}
|
||||
{show && label && (
|
||||
<span
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 50,
|
||||
...pos,
|
||||
padding: "5px 9px",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: "var(--ctv-surface-3)",
|
||||
border: "1px solid var(--border-control)",
|
||||
boxShadow: "var(--shadow-pop)",
|
||||
color: "var(--text-primary)",
|
||||
font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)",
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
**Tooltip** — hover label for icon buttons and truncated text. Wrap the trigger element.
|
||||
|
||||
```jsx
|
||||
<Tooltip label="Reset playout"><IconButton title="Reset"><Ico n="RefreshCw"/></IconButton></Tooltip>
|
||||
```
|
||||
|
||||
Props: `label`, `placement` (top·bottom·left·right).
|
||||
@@ -0,0 +1,50 @@
|
||||
<!-- @dsCard group="Components" viewport="700x260" name="Feedback" subtitle="Toast, Tooltip, Spinner" -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../../styles.css">
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="../../_ds_bundle.js"></script>
|
||||
<style>
|
||||
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
|
||||
#root { padding: 22px 24px; display: flex; gap: 22px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-disabled); margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Toast, Tooltip, Spinner, IconButton } = NS;
|
||||
const Ico = ({ n, s = 16 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current){ r.current.innerHTML=""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el);} }); return <span ref={r} style={{ display: "inline-flex" }} />; };
|
||||
|
||||
function Demo() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, width: 380 }}>
|
||||
<Toast tone="ok" title="Channel saved" message="Retro Cartoons is now on air." onClose={() => {}} />
|
||||
<Toast tone="warn" title="FFmpeg profile is not normalizing" message="Playback may stutter in some browsers." onClose={() => {}} />
|
||||
<Toast tone="error" title="Playout build failed" message="No items match the schedule window." onClose={() => {}} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="lbl">Tooltip · Spinner</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<Tooltip label="Reset playout"><IconButton title="Reset"><Ico n="RefreshCw" /></IconButton></Tooltip>
|
||||
<Tooltip label="Troubleshoot" placement="bottom"><IconButton title="Info"><Ico n="Info" /></IconButton></Tooltip>
|
||||
<Spinner size={18} tone="accent" />
|
||||
<Spinner size={18} tone="muted" />
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* @startingPoint section="Forms" subtitle="Buttons in every variant & size" viewport="700x330"
|
||||
*/
|
||||
export interface ButtonProps {
|
||||
children?: React.ReactNode;
|
||||
/** Visual role. `primary` is the single accent action; use one per view. */
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
size?: "sm" | "md";
|
||||
/** Icon element rendered before the label (16px stroke icon). */
|
||||
startIcon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Shows an inline spinner and disables the button. */
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
type?: "button" | "submit" | "reset";
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary action button for ChicoryTV. One `primary` per view; everything else
|
||||
* is `secondary`/`ghost`. `danger` for destructive confirmations.
|
||||
*/
|
||||
export function Button(props: ButtonProps): JSX.Element;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
|
||||
const sizeMap = {
|
||||
sm: { height: "var(--control-h-sm)", padding: "0 12px", font: "var(--text-xs)" },
|
||||
md: { height: "var(--control-h)", padding: "0 14px", font: "var(--text-sm)" },
|
||||
};
|
||||
|
||||
const variantStyle = {
|
||||
primary: {
|
||||
background: "var(--action-primary)",
|
||||
color: "var(--text-on-accent)",
|
||||
border: "1px solid transparent",
|
||||
},
|
||||
secondary: {
|
||||
background: "var(--ctv-surface-2)",
|
||||
color: "var(--text-primary)",
|
||||
border: "1px solid var(--border-control)",
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid transparent",
|
||||
},
|
||||
danger: {
|
||||
background: "var(--ctv-error-soft)",
|
||||
color: "var(--status-error)",
|
||||
border: "1px solid rgba(229,72,77,0.35)",
|
||||
},
|
||||
};
|
||||
|
||||
const hoverBg = {
|
||||
primary: "var(--action-primary-hover)",
|
||||
secondary: "var(--ctv-surface-3)",
|
||||
ghost: "var(--ctv-surface-2)",
|
||||
danger: "rgba(229,72,77,0.22)",
|
||||
};
|
||||
|
||||
/**
|
||||
* ChicoryTV primary action button.
|
||||
*/
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
startIcon = null,
|
||||
endIcon = null,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
onClick,
|
||||
type = "button",
|
||||
style = {},
|
||||
...rest
|
||||
}) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const [active, setActive] = React.useState(false);
|
||||
const s = sizeMap[size] || sizeMap.md;
|
||||
const v = variantStyle[variant] || variantStyle.primary;
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => { setHover(false); setActive(false); }}
|
||||
onMouseDown={() => setActive(true)}
|
||||
onMouseUp={() => setActive(false)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "7px",
|
||||
height: s.height,
|
||||
padding: s.padding,
|
||||
width: fullWidth ? "100%" : "auto",
|
||||
font: `var(--weight-medium) ${s.font}/1 var(--font-sans)`,
|
||||
letterSpacing: "0.01em",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
opacity: isDisabled ? 0.45 : 1,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "background var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard)",
|
||||
transform: active && !isDisabled ? "translateY(0.5px)" : "none",
|
||||
...v,
|
||||
background: !isDisabled && hover ? hoverBg[variant] : v.background,
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Spinner16 /> : startIcon}
|
||||
{children}
|
||||
{!loading && endIcon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner16() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ animation: "ctv-spin 0.7s linear infinite" }}>
|
||||
<style>{`@keyframes ctv-spin{to{transform:rotate(360deg)}}`}</style>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
**Button** — the primary action control. Use exactly one `primary` button per view (add channel, save); everything else is `secondary` or `ghost`. `danger` is reserved for destructive confirmations.
|
||||
|
||||
```jsx
|
||||
<Button variant="primary" startIcon={<PlusIcon/>}>Add Channel</Button>
|
||||
<Button variant="secondary">Edit Numbers</Button>
|
||||
<Button variant="ghost" size="sm">Cancel</Button>
|
||||
<Button variant="danger">Delete</Button>
|
||||
<Button variant="primary" loading>Saving…</Button>
|
||||
```
|
||||
|
||||
Props: `variant` (primary·secondary·ghost·danger), `size` (sm·md), `startIcon`/`endIcon`, `loading`, `disabled`, `fullWidth`.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onChange?: (next: boolean) => void;
|
||||
label?: string;
|
||||
/** Mixed state for "select all" headers. */
|
||||
indeterminate?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox for multi-select rows and option lists; supports indeterminate.
|
||||
*/
|
||||
export function Checkbox(props: CheckboxProps): JSX.Element;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Checkbox with label. Controlled via checked/onChange.
|
||||
*/
|
||||
export function Checkbox({ checked = false, onChange, disabled = false, label, indeterminate = false, style = {} }) {
|
||||
const toggle = () => { if (!disabled && onChange) onChange(!checked); };
|
||||
const on = checked || indeterminate;
|
||||
return (
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: "8px", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style }}>
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
onClick={toggle}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 17,
|
||||
height: 17,
|
||||
flex: "0 0 auto",
|
||||
borderRadius: "var(--radius-xs)",
|
||||
background: on ? "var(--action-primary)" : "var(--ctv-bg-sunken)",
|
||||
border: `1px solid ${on ? "transparent" : "var(--border-control)"}`,
|
||||
transition: "background var(--dur-fast), border-color var(--dur-fast)",
|
||||
}}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24"><path d="M6 12h12" stroke="#fff" strokeWidth="3" strokeLinecap="round" /></svg>
|
||||
) : checked ? (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M5 12.5l4.5 4.5L19 7" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
) : null}
|
||||
</span>
|
||||
{label && <span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**Checkbox** — multi-select in tables (collection items, bulk actions) and option lists. Use `indeterminate` on a header "select all".
|
||||
|
||||
```jsx
|
||||
<Checkbox checked={sel} onChange={setSel} label="Include in EPG" />
|
||||
<Checkbox indeterminate onChange={selectAll} />
|
||||
```
|
||||
|
||||
Props: `checked`, `onChange(next)`, `label`, `indeterminate`, `disabled`.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface IconButtonProps {
|
||||
/** A 16–18px stroke icon element. */
|
||||
children?: React.ReactNode;
|
||||
size?: "sm" | "md";
|
||||
variant?: "ghost" | "solid";
|
||||
/** Persistent pressed/selected state (e.g. active view toggle). */
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Tooltip + aria-label — always provide one. */
|
||||
title?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square icon-only button for dense row actions and toolbars.
|
||||
*/
|
||||
export function IconButton(props: IconButtonProps): JSX.Element;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Compact square icon button — table row actions, toolbars.
|
||||
*/
|
||||
export function IconButton({
|
||||
children,
|
||||
size = "md",
|
||||
variant = "ghost",
|
||||
disabled = false,
|
||||
active = false,
|
||||
title,
|
||||
onClick,
|
||||
style = {},
|
||||
...rest
|
||||
}) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const dim = size === "sm" ? 28 : 32;
|
||||
|
||||
const base =
|
||||
variant === "solid"
|
||||
? { background: "var(--action-primary)", color: "var(--text-on-accent)" }
|
||||
: { background: active ? "var(--ctv-surface-3)" : "transparent", color: active ? "var(--text-primary)" : "var(--text-secondary)" };
|
||||
|
||||
const hoverBg =
|
||||
variant === "solid" ? "var(--action-primary-hover)" : "var(--ctv-surface-2)";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: dim,
|
||||
height: dim,
|
||||
flex: "0 0 auto",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
border: "1px solid transparent",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
color: base.color,
|
||||
background: !disabled && hover ? hoverBg : base.background,
|
||||
transition: "background var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard)",
|
||||
...(!disabled && hover && variant !== "solid" ? { color: "var(--text-primary)" } : {}),
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
**IconButton** — icon-only square button for table row actions (edit, preview, delete) and toolbars. Always pass `title` for the tooltip/aria-label.
|
||||
|
||||
```jsx
|
||||
<IconButton title="Edit channel"><EditIcon/></IconButton>
|
||||
<IconButton title="Preview" variant="solid"><PlayIcon/></IconButton>
|
||||
<IconButton title="Delete" disabled><TrashIcon/></IconButton>
|
||||
```
|
||||
|
||||
Props: `size` (sm·md), `variant` (ghost·solid), `active`, `disabled`, `title`.
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface InputProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
/** 16px stroke icon shown inside, left of the field (e.g. search). */
|
||||
leadingIcon?: React.ReactNode;
|
||||
/** Element pinned to the right inside the field (clear button, unit). */
|
||||
trailing?: React.ReactNode;
|
||||
/** Error message — turns the border red and shows text below. */
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
type?: string;
|
||||
fullWidth?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text field on a sunken well with focus ring and optional icon/error.
|
||||
*/
|
||||
export function Input(props: InputProps): JSX.Element;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Text input with optional label, leading icon and error state.
|
||||
*/
|
||||
export function Input({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
leadingIcon = null,
|
||||
trailing = null,
|
||||
error = null,
|
||||
disabled = false,
|
||||
size = "md",
|
||||
type = "text",
|
||||
fullWidth = true,
|
||||
style = {},
|
||||
...rest
|
||||
}) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
const height = size === "sm" ? "var(--control-h-sm)" : "var(--control-h)";
|
||||
const borderColor = error
|
||||
? "var(--status-error)"
|
||||
: focus
|
||||
? "var(--action-primary)"
|
||||
: "var(--border-control)";
|
||||
|
||||
return (
|
||||
<label style={{ display: "block", width: fullWidth ? "100%" : "auto" }}>
|
||||
{label && (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "6px",
|
||||
font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
height,
|
||||
padding: "0 10px",
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
boxShadow: focus && !error ? "0 0 0 3px var(--focus-ring)" : "none",
|
||||
transition: "border-color var(--dur-fast) var(--ease-standard), box-shadow var(--dur-fast) var(--ease-standard)",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{leadingIcon && (
|
||||
<span style={{ display: "flex", color: "var(--text-disabled)", flex: "0 0 auto" }}>{leadingIcon}</span>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "var(--text-primary)",
|
||||
font: "var(--weight-normal) var(--text-sm)/1 var(--font-sans)",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
{trailing && <span style={{ display: "flex", flex: "0 0 auto" }}>{trailing}</span>}
|
||||
</span>
|
||||
{error && (
|
||||
<span style={{ display: "block", marginTop: "5px", font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--status-error)" }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
**Input** — text field on a sunken well. Supports label, leading icon (search), trailing element, and error state.
|
||||
|
||||
```jsx
|
||||
<Input label="Channel name" value={v} onChange={e=>set(e.target.value)} />
|
||||
<Input leadingIcon={<SearchIcon/>} placeholder="Search channels…" />
|
||||
<Input label="Number" error="Already in use" value="5.1" />
|
||||
```
|
||||
|
||||
Props: `label`, `leadingIcon`, `trailing`, `error`, `size` (sm·md), `disabled`, `type`.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
/** Options as strings or {value,label} objects. */
|
||||
options?: (string | SelectOption)[];
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
fullWidth?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native-backed select with ChicoryTV chrome and a custom chevron.
|
||||
*/
|
||||
export function Select(props: SelectProps): JSX.Element;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Styled select built on a native <select> for accessibility.
|
||||
*/
|
||||
export function Select({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
disabled = false,
|
||||
size = "md",
|
||||
fullWidth = true,
|
||||
style = {},
|
||||
...rest
|
||||
}) {
|
||||
const [focus, setFocus] = React.useState(false);
|
||||
const height = size === "sm" ? "var(--control-h-sm)" : "var(--control-h)";
|
||||
const opts = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o));
|
||||
|
||||
return (
|
||||
<label style={{ display: "block", width: fullWidth ? "100%" : "auto" }}>
|
||||
{label && (
|
||||
<span style={{ display: "block", marginBottom: "6px", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height,
|
||||
background: "var(--ctv-bg-sunken)",
|
||||
border: `1px solid ${focus ? "var(--action-primary)" : "var(--border-control)"}`,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
boxShadow: focus ? "0 0 0 3px var(--focus-ring)" : "none",
|
||||
transition: "border-color var(--dur-fast), box-shadow var(--dur-fast)",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
onFocus={() => setFocus(true)}
|
||||
onBlur={() => setFocus(false)}
|
||||
style={{
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
padding: "0 30px 0 10px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "var(--text-primary)",
|
||||
font: "var(--weight-normal) var(--text-sm)/1 var(--font-sans)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{opts.map((o) => (
|
||||
<option key={o.value} value={o.value} style={{ background: "var(--ctv-surface-2)", color: "var(--text-primary)" }}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ position: "absolute", right: 9, pointerEvents: "none", color: "var(--text-disabled)" }}>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
**Select** — dropdown built on a native `<select>` for keyboard/a11y, dressed in ChicoryTV chrome.
|
||||
|
||||
```jsx
|
||||
<Select label="Streaming mode"
|
||||
value={mode} onChange={e=>setMode(e.target.value)}
|
||||
options={["HLS Segmenter","HLS Direct","MPEG-TS"]} />
|
||||
```
|
||||
|
||||
Options accept plain strings or `{value,label}`. Props: `label`, `size` (sm·md), `disabled`.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface SwitchProps {
|
||||
checked?: boolean;
|
||||
onChange?: (next: boolean) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean toggle — "Show disabled", "Enabled in EPG", filler visibility.
|
||||
*/
|
||||
export function Switch(props: SwitchProps): JSX.Element;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* On/off toggle switch.
|
||||
*/
|
||||
export function Switch({ checked = false, onChange, disabled = false, label, size = "md", style = {} }) {
|
||||
const w = size === "sm" ? 30 : 36;
|
||||
const h = size === "sm" ? 17 : 20;
|
||||
const knob = h - 5;
|
||||
|
||||
const toggle = () => { if (!disabled && onChange) onChange(!checked); };
|
||||
|
||||
return (
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: "9px", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style }}>
|
||||
<span
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={toggle}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: w,
|
||||
height: h,
|
||||
flex: "0 0 auto",
|
||||
borderRadius: "var(--radius-pill)",
|
||||
background: checked ? "var(--action-primary)" : "var(--ctv-surface-3)",
|
||||
border: `1px solid ${checked ? "transparent" : "var(--border-control)"}`,
|
||||
transition: "background var(--dur-base) var(--ease-standard)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: checked ? `calc(100% - ${knob + 2}px)` : "2px",
|
||||
transform: "translateY(-50%)",
|
||||
width: knob,
|
||||
height: knob,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.5)",
|
||||
transition: "left var(--dur-base) var(--ease-out)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
{label && <span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
**Switch** — instant on/off toggle for settings and table filters ("Show disabled", "Show filler"). Track turns accent when on.
|
||||
|
||||
```jsx
|
||||
<Switch checked={showDisabled} onChange={setShowDisabled} label="Show disabled" />
|
||||
```
|
||||
|
||||
Props: `checked`, `onChange(next)`, `label`, `size` (sm·md), `disabled`.
|
||||
@@ -0,0 +1,64 @@
|
||||
<!-- @dsCard group="Components" viewport="700x330" name="Forms" subtitle="Button, IconButton, Input, Select, Switch, Checkbox" -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../../styles.css">
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="../../_ds_bundle.js"></script>
|
||||
<style>
|
||||
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
|
||||
#root { padding: 22px 24px; }
|
||||
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 28px; max-width: 620px; }
|
||||
.lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-disabled); margin-bottom: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { Button, IconButton, Input, Select, Switch, Checkbox } = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const Ico = ({ n, s = 16 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current) r.current.innerHTML = ""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el); }); return <span ref={r} style={{ display: "inline-flex" }} />; };
|
||||
|
||||
function Demo() {
|
||||
const [sw, setSw] = React.useState(true);
|
||||
const [cb, setCb] = React.useState(true);
|
||||
return (
|
||||
<div>
|
||||
<div className="row">
|
||||
<Button variant="primary" startIcon={<Ico n="Plus" />}>Add Channel</Button>
|
||||
<Button variant="secondary" startIcon={<Ico n="Pencil" />}>Edit Numbers</Button>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Button variant="danger" startIcon={<Ico n="Trash2" />}>Delete</Button>
|
||||
<Button variant="primary" loading>Saving</Button>
|
||||
<Button variant="primary" disabled>Disabled</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<IconButton title="Preview" variant="solid"><Ico n="Play" /></IconButton>
|
||||
<IconButton title="Edit"><Ico n="Pencil" /></IconButton>
|
||||
<IconButton title="Refresh"><Ico n="RefreshCw" /></IconButton>
|
||||
<IconButton title="Info"><Ico n="Info" /></IconButton>
|
||||
<IconButton title="Delete"><Ico n="Trash2" /></IconButton>
|
||||
<IconButton title="Disabled" disabled><Ico n="Play" /></IconButton>
|
||||
<span style={{ width: 16 }} />
|
||||
<Switch checked={sw} onChange={setSw} label="Show disabled" />
|
||||
<Checkbox checked={cb} onChange={setCb} label="In EPG" />
|
||||
</div>
|
||||
<div className="grid">
|
||||
<Input label="Channel name" value="Retro Cartoons" onChange={() => {}} />
|
||||
<Select label="Streaming mode" value="HLS Segmenter" onChange={() => {}} options={["HLS Segmenter", "HLS Direct", "MPEG-TS"]} />
|
||||
<Input leadingIcon={<Ico n="Search" />} placeholder="Search channels…" />
|
||||
<Input label="Channel number" value="5.1" error="Already in use" onChange={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface NavItemProps {
|
||||
/** 16–18px stroke icon. */
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
/** Count badge (playout warnings, health errors). */
|
||||
badge?: React.ReactNode;
|
||||
badgeTone?: "warn" | "error" | "accent";
|
||||
href?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* One left-sidebar nav row; active state shows an accent rail + tint.
|
||||
*/
|
||||
export function NavItem(props: NavItemProps): JSX.Element;
|
||||
|
||||
export interface NavSectionProps {
|
||||
children?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Uppercase eyebrow grouping a set of NavItems. */
|
||||
export function NavSection(props: NavSectionProps): JSX.Element;
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Single left-sidebar navigation item. Compose a list of these.
|
||||
*/
|
||||
export function NavItem({ icon = null, label, active = false, badge = null, badgeTone = "warn", onClick, href, style = {} }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const Comp = href ? "a" : "button";
|
||||
const bt = { warn: "var(--status-warn)", error: "var(--status-error)", accent: "var(--action-primary)" }[badgeTone];
|
||||
return (
|
||||
<Comp
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
width: "100%",
|
||||
height: "34px",
|
||||
padding: "0 10px",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
font: `${active ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-sm)/1 var(--font-sans)`,
|
||||
color: active ? "var(--text-primary)" : hover ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
background: active ? "var(--ctv-accent-soft)" : hover ? "var(--ctv-surface-2)" : "transparent",
|
||||
transition: "background var(--dur-fast) var(--ease-standard), color var(--dur-fast)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{active && <span style={{ position: "absolute", left: 0, top: 7, bottom: 7, width: 2.5, borderRadius: 2, background: "var(--action-primary)" }} />}
|
||||
{icon && <span style={{ display: "inline-flex", color: active ? "var(--action-primary)" : "currentColor", flex: "0 0 auto" }}>{icon}</span>}
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
|
||||
{badge != null && (
|
||||
<span style={{ minWidth: 17, height: 17, padding: "0 5px", display: "inline-flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-pill)", background: bt, color: badgeTone === "warn" ? "#1A1206" : "#fff", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)" }}>{badge}</span>
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar section label (eyebrow) that groups NavItems.
|
||||
*/
|
||||
export function NavSection({ children, style = {} }) {
|
||||
return (
|
||||
<div style={{ padding: "14px 10px 6px", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "var(--tracking-caps)", textTransform: "uppercase", color: "var(--text-disabled)", ...style }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
**NavItem / NavSection** — building blocks of the left sidebar. Active item gets an accent rail, tint, and accent icon. `NavSection` is the uppercase group eyebrow. Count badges surface playout warnings / health errors.
|
||||
|
||||
```jsx
|
||||
<NavSection>Scheduling</NavSection>
|
||||
<NavItem icon={<Ico n="Tv"/>} label="Channels" active />
|
||||
<NavItem icon={<Ico n="CalendarClock"/>} label="Playouts" badge={3} badgeTone="warn" />
|
||||
```
|
||||
|
||||
Props (NavItem): `icon`, `label`, `active`, `badge`, `badgeTone` (warn·error·accent), `href`/`onClick`.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TabItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
/** Strings or {value,label,icon,count}. */
|
||||
tabs: (string | TabItem)[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Underlined tab bar for editor sub-views (Channel: General / Streaming / Watermark).
|
||||
*/
|
||||
export function Tabs(props: TabsProps): JSX.Element;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Underlined tab bar. Controlled via value/onChange.
|
||||
*/
|
||||
export function Tabs({ tabs = [], value, onChange, style = {} }) {
|
||||
const items = tabs.map((t) => (typeof t === "string" ? { value: t, label: t } : t));
|
||||
const active = value ?? items[0]?.value;
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "2px", borderBottom: "1px solid var(--border-hairline)", ...style }}>
|
||||
{items.map((t) => {
|
||||
const on = t.value === active;
|
||||
return <Tab key={t.value} tab={t} on={on} onChange={onChange} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ tab, on, onChange }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange && onChange(tab.value)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "7px",
|
||||
height: "34px",
|
||||
padding: "0 12px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-sm)/1 var(--font-sans)`,
|
||||
color: on ? "var(--text-primary)" : hover ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
transition: "color var(--dur-fast)",
|
||||
}}
|
||||
>
|
||||
{tab.icon && <span style={{ display: "inline-flex", color: on ? "var(--action-primary)" : "currentColor" }}>{tab.icon}</span>}
|
||||
{tab.label}
|
||||
{tab.count != null && (
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)", background: "var(--ctv-surface-3)", padding: "2px 5px", borderRadius: "var(--radius-xs)" }}>{tab.count}</span>
|
||||
)}
|
||||
<span style={{ position: "absolute", left: 6, right: 6, bottom: -1, height: 2, borderRadius: 2, background: on ? "var(--action-primary)" : "transparent", transition: "background var(--dur-fast)" }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**Tabs** — underlined tab bar for editor sub-views and detail panes (Channel → General / Streaming / Watermark; Playout → Detail / Log). Active tab shows an accent underline.
|
||||
|
||||
```jsx
|
||||
<Tabs value={tab} onChange={setTab}
|
||||
tabs={[{value:"general",label:"General"},{value:"streaming",label:"Streaming"},{value:"items",label:"Items",count:12}]} />
|
||||
```
|
||||
|
||||
Props: `tabs` (string | {value,label,icon,count}), `value`, `onChange`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user