diff --git a/.gitignore b/.gitignore index 7433967..ff0421f 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,5 @@ packages/ *.backup *.orig -dotnet/.vs/** \ No newline at end of file +dotnet/.vs/** +.vs/** \ No newline at end of file diff --git a/Twitch-Archive-2.sln b/Twitch-Archive-2.sln deleted file mode 100644 index a41b4f2..0000000 --- a/Twitch-Archive-2.sln +++ /dev/null @@ -1,32 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet", "dotnet", "{71E6E750-85FD-B5BC-4321-E01377EC6231}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D90AB541-7400-80B1-A0B4-F58D0D439F55}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchArchive.Core", "dotnet\src\TwitchArchive.Core\TwitchArchive.Core.csproj", "{1D11D744-6D0D-BB4D-8B77-30B5CE764821}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {D90AB541-7400-80B1-A0B4-F58D0D439F55} = {71E6E750-85FD-B5BC-4321-E01377EC6231} - {1D11D744-6D0D-BB4D-8B77-30B5CE764821} = {D90AB541-7400-80B1-A0B4-F58D0D439F55} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {D101688C-0CA3-4CFB-96D4-E1AB9A62EC51} - EndGlobalSection -EndGlobal diff --git a/dotnet/src/TwitchArchive.Core/ArchiveDbContextFactory.cs b/dotnet/src/TwitchArchive.Core/ArchiveDbContextFactory.cs new file mode 100644 index 0000000..984f7c5 --- /dev/null +++ b/dotnet/src/TwitchArchive.Core/ArchiveDbContextFactory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using TwitchArchive.Core.Persistence; + +namespace TwitchArchive.Core +{ + public class ArchiveDbContextFactory : IDesignTimeDbContextFactory + { + public ArchiveDbContext CreateDbContext(string[] args) + { + var builder = new DbContextOptionsBuilder(); + var conn = "Data Source=archive.db"; + builder.UseSqlite(conn); + return new ArchiveDbContext(builder.Options); + } + } +} diff --git a/dotnet/src/TwitchArchive.Core/Config/ConfigurationService.cs b/dotnet/src/TwitchArchive.Core/Config/ConfigurationService.cs index dc76fde..9dc3ba6 100644 --- a/dotnet/src/TwitchArchive.Core/Config/ConfigurationService.cs +++ b/dotnet/src/TwitchArchive.Core/Config/ConfigurationService.cs @@ -14,12 +14,43 @@ namespace TwitchArchive.Core.Config public ConfigurationService(string? basePath = null) { - _basePath = basePath ?? Path.Combine(AppContext.BaseDirectory, "config"); + // If a basePath was explicitly provided, use it. Otherwise try to find a + // repository-level `config` folder by walking parent directories from the + // application base. This ensures the web app uses the same config/ files + // as the repository (global.json, config/streamers/*.json) when available. + if (!string.IsNullOrWhiteSpace(basePath)) + { + _basePath = basePath; + } + else + { + var found = FindExistingConfigFolder(); + _basePath = found ?? Path.Combine(AppContext.BaseDirectory, "config"); + } + _streamersPath = Path.Combine(_basePath, "streamers"); Directory.CreateDirectory(_basePath); Directory.CreateDirectory(_streamersPath); } + private string? FindExistingConfigFolder() + { + var start = AppContext.BaseDirectory ?? Environment.CurrentDirectory; + var dir = new DirectoryInfo(start); + for (int i = 0; i < 8 && dir != null; i++) + { + var candidate = Path.Combine(dir.FullName, "config"); + if (Directory.Exists(candidate)) + { + // prefer candidate if it contains global.json or streamers + if (File.Exists(Path.Combine(candidate, "global.json")) || Directory.Exists(Path.Combine(candidate, "streamers"))) + return candidate; + } + dir = dir.Parent; + } + return null; + } + public GlobalConfig LoadGlobal() { var file = Path.Combine(_basePath, "global.json"); diff --git a/dotnet/src/TwitchArchive.Core/Config/EffectiveConfig.cs b/dotnet/src/TwitchArchive.Core/Config/EffectiveConfig.cs index 82c8c4a..2fecf71 100644 --- a/dotnet/src/TwitchArchive.Core/Config/EffectiveConfig.cs +++ b/dotnet/src/TwitchArchive.Core/Config/EffectiveConfig.cs @@ -14,10 +14,44 @@ namespace TwitchArchive.Core.Config public int RefreshIntervalSeconds { get; init; } public int StreamSegmentThreads { get; init; } public string? DefaultQuality { get; init; } + // Defaults that can be overridden per-streamer + public DefaultsSection Defaults { get; init; } = new DefaultsSection(); public static EffectiveConfig Merge(GlobalConfig global, StreamerConfig? streamer) { streamer ??= new StreamerConfig(); + var d = new TwitchArchive.Core.Config.DefaultsSection(); + // start with global defaults + if (global?.Defaults != null) d = global.Defaults; + // apply per-streamer overrides when present + var mergedDefaults = new TwitchArchive.Core.Config.DefaultsSection + { + DownloadVOD = streamer.DownloadVOD ?? d.DownloadVOD, + DownloadCHAT = streamer.DownloadCHAT ?? d.DownloadCHAT, + DownloadLiveCHAT = streamer.DownloadLiveCHAT ?? d.DownloadLiveCHAT, + MergeVideoChat = streamer.MergeVideoChat ?? d.MergeVideoChat, + MergeChatLayout = streamer.MergeChatLayout ?? d.MergeChatLayout, + VodTimeout = streamer.VodTimeout ?? d.VodTimeout, + UploadCloud = streamer.UploadCloud ?? d.UploadCloud, + UploadPreMergeVideo = streamer.UploadPreMergeVideo ?? d.UploadPreMergeVideo, + UploadMergedVideo = streamer.UploadMergedVideo ?? d.UploadMergedVideo, + UploadChatVideo = streamer.UploadChatVideo ?? d.UploadChatVideo, + DeleteFiles = streamer.DeleteFiles ?? d.DeleteFiles, + OnlyRaw = streamer.OnlyRaw ?? d.OnlyRaw, + CleanRaw = streamer.CleanRaw ?? d.CleanRaw, + HlsSegments = streamer.HlsSegments ?? d.HlsSegments, + HlsSegmentsVOD = streamer.HlsSegmentsVOD ?? d.HlsSegmentsVOD, + StreamlinkTtvlol = streamer.StreamlinkTtvlol ?? d.StreamlinkTtvlol, + FfmpegHwaccel = streamer.FfmpegHwaccel ?? d.FfmpegHwaccel, + FfmpegThreads = streamer.FfmpegThreads ?? d.FfmpegThreads, + FfmpegAudioCodec = streamer.FfmpegAudioCodec ?? d.FfmpegAudioCodec, + FfmpegAudioSamplerate = streamer.FfmpegAudioSamplerate ?? d.FfmpegAudioSamplerate, + FfmpegAudioBitrate = streamer.FfmpegAudioBitrate ?? d.FfmpegAudioBitrate, + FfmpegErrorRecovery = streamer.FfmpegErrorRecovery ?? d.FfmpegErrorRecovery, + FfmpegFaststart = streamer.FfmpegFaststart ?? d.FfmpegFaststart, + FfmpegProgress = streamer.FfmpegProgress ?? d.FfmpegProgress + }; + return new EffectiveConfig { ArchiveRoot = streamer.Username != null ? (global.ArchiveRoot ?? string.Empty) : (global.ArchiveRoot ?? string.Empty), @@ -29,7 +63,8 @@ namespace TwitchArchive.Core.Config UploadDestination = streamer.UploadDestination ?? global.UploadDestination, RefreshIntervalSeconds = global.RefreshIntervalSeconds, StreamSegmentThreads = global.StreamSegmentThreads, - DefaultQuality = streamer.Quality ?? global.DefaultQuality + DefaultQuality = streamer.Quality ?? global.DefaultQuality, + Defaults = mergedDefaults }; } } diff --git a/dotnet/src/TwitchArchive.Core/Config/GlobalConfig.cs b/dotnet/src/TwitchArchive.Core/Config/GlobalConfig.cs index cb7fecf..840ba8e 100644 --- a/dotnet/src/TwitchArchive.Core/Config/GlobalConfig.cs +++ b/dotnet/src/TwitchArchive.Core/Config/GlobalConfig.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace TwitchArchive.Core.Config @@ -26,12 +27,98 @@ namespace TwitchArchive.Core.Config public string? UploadDestination { get; set; } [JsonPropertyName("refresh_interval_seconds")] + [Range(5, 86400, ErrorMessage = "Refresh interval must be between 5 and 86400 seconds.")] public int RefreshIntervalSeconds { get; set; } = 60; [JsonPropertyName("stream_segment_threads")] + [Range(1, 64, ErrorMessage = "Stream segment threads must be between 1 and 64.")] public int StreamSegmentThreads { get; set; } = 4; [JsonPropertyName("default_quality")] public string? DefaultQuality { get; set; } = "best"; + + // Defaults section for per-streamer fallbacks + [JsonPropertyName("defaults")] + public DefaultsSection Defaults { get; set; } = new DefaultsSection(); + } + + public class DefaultsSection + { + [JsonPropertyName("downloadVOD")] + public bool DownloadVOD { get; set; } = true; + + [JsonPropertyName("downloadCHAT")] + public bool DownloadCHAT { get; set; } = true; + + [JsonPropertyName("downloadLiveCHAT")] + public bool DownloadLiveCHAT { get; set; } = true; + + [JsonPropertyName("mergeVideoChat")] + public bool MergeVideoChat { get; set; } = false; + + [JsonPropertyName("mergeChatLayout")] + public string MergeChatLayout { get; set; } = "side-by-side"; + + [JsonPropertyName("vodTimeout")] + [Range(0, 86400, ErrorMessage = "VOD timeout must be between 0 and 86400 seconds.")] + public int VodTimeout { get; set; } = 300; + + [JsonPropertyName("uploadCloud")] + public bool UploadCloud { get; set; } = false; + + [JsonPropertyName("uploadPreMergeVideo")] + public bool UploadPreMergeVideo { get; set; } = true; + + [JsonPropertyName("uploadMergedVideo")] + public bool UploadMergedVideo { get; set; } = true; + + [JsonPropertyName("uploadChatVideo")] + public bool UploadChatVideo { get; set; } = false; + + [JsonPropertyName("deleteFiles")] + public bool DeleteFiles { get; set; } = false; + + [JsonPropertyName("onlyRaw")] + public bool OnlyRaw { get; set; } = false; + + [JsonPropertyName("cleanRaw")] + public bool CleanRaw { get; set; } = true; + + [JsonPropertyName("hls_segments")] + [Range(1, 50, ErrorMessage = "HLS segments must be between 1 and 50.")] + public int HlsSegments { get; set; } = 3; + + [JsonPropertyName("hls_segmentsVOD")] + [Range(1, 200, ErrorMessage = "HLS segments (VOD) must be between 1 and 200.")] + public int HlsSegmentsVOD { get; set; } = 10; + + [JsonPropertyName("streamlink_ttvlol")] + public bool StreamlinkTtvlol { get; set; } = false; + + [JsonPropertyName("ffmpeg_hwaccel")] + public string FfmpegHwaccel { get; set; } = "auto"; + + [JsonPropertyName("ffmpeg_threads")] + [Range(0, 128, ErrorMessage = "FFmpeg threads must be between 0 and 128.")] + public int FfmpegThreads { get; set; } = 0; + + [JsonPropertyName("ffmpeg_audio_codec")] + public string FfmpegAudioCodec { get; set; } = "aac"; + + [JsonPropertyName("ffmpeg_audio_samplerate")] + [Range(8000, 192000, ErrorMessage = "Audio sample rate must be between 8000 and 192000.")] + public int FfmpegAudioSamplerate { get; set; } = 48000; + + [JsonPropertyName("ffmpeg_audio_bitrate")] + public string FfmpegAudioBitrate { get; set; } = "192k"; + + [JsonPropertyName("ffmpeg_error_recovery")] + public bool FfmpegErrorRecovery { get; set; } = true; + + [JsonPropertyName("ffmpeg_faststart")] + public bool FfmpegFaststart { get; set; } = true; + + [JsonPropertyName("ffmpeg_progress")] + public bool FfmpegProgress { get; set; } = false; } } diff --git a/dotnet/src/TwitchArchive.Core/Config/StreamerConfig.cs b/dotnet/src/TwitchArchive.Core/Config/StreamerConfig.cs index 1c3a334..e490785 100644 --- a/dotnet/src/TwitchArchive.Core/Config/StreamerConfig.cs +++ b/dotnet/src/TwitchArchive.Core/Config/StreamerConfig.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; namespace TwitchArchive.Core.Config @@ -5,6 +6,7 @@ namespace TwitchArchive.Core.Config public class StreamerConfig { [JsonPropertyName("username")] + [Required] public string Username { get; set; } = string.Empty; [JsonPropertyName("enabled")] @@ -21,5 +23,83 @@ namespace TwitchArchive.Core.Config [JsonPropertyName("streamlink_path")] public string? StreamlinkPath { get; set; } + + // Per-streamer override options matching GlobalConfig.Defaults + [JsonPropertyName("downloadVOD")] + public bool? DownloadVOD { get; set; } + + [JsonPropertyName("downloadCHAT")] + public bool? DownloadCHAT { get; set; } + + [JsonPropertyName("downloadLiveCHAT")] + public bool? DownloadLiveCHAT { get; set; } + + [JsonPropertyName("mergeVideoChat")] + public bool? MergeVideoChat { get; set; } + + [JsonPropertyName("mergeChatLayout")] + public string? MergeChatLayout { get; set; } + + [JsonPropertyName("vodTimeout")] + [Range(0, 86400, ErrorMessage = "VOD timeout must be between 0 and 86400 seconds.")] + public int? VodTimeout { get; set; } + + [JsonPropertyName("uploadCloud")] + public bool? UploadCloud { get; set; } + + [JsonPropertyName("uploadPreMergeVideo")] + public bool? UploadPreMergeVideo { get; set; } + + [JsonPropertyName("uploadMergedVideo")] + public bool? UploadMergedVideo { get; set; } + + [JsonPropertyName("uploadChatVideo")] + public bool? UploadChatVideo { get; set; } + + [JsonPropertyName("deleteFiles")] + public bool? DeleteFiles { get; set; } + + [JsonPropertyName("onlyRaw")] + public bool? OnlyRaw { get; set; } + + [JsonPropertyName("cleanRaw")] + public bool? CleanRaw { get; set; } + + [JsonPropertyName("hls_segments")] + [Range(1, 50, ErrorMessage = "HLS segments must be between 1 and 50.")] + public int? HlsSegments { get; set; } + + [JsonPropertyName("hls_segmentsVOD")] + [Range(1, 200, ErrorMessage = "HLS segments (VOD) must be between 1 and 200.")] + public int? HlsSegmentsVOD { get; set; } + + [JsonPropertyName("streamlink_ttvlol")] + public bool? StreamlinkTtvlol { get; set; } + + [JsonPropertyName("ffmpeg_hwaccel")] + public string? FfmpegHwaccel { get; set; } + + [JsonPropertyName("ffmpeg_threads")] + [Range(0, 128, ErrorMessage = "FFmpeg threads must be between 0 and 128.")] + public int? FfmpegThreads { get; set; } + + [JsonPropertyName("ffmpeg_audio_codec")] + public string? FfmpegAudioCodec { get; set; } + + [JsonPropertyName("ffmpeg_audio_samplerate")] + [Range(8000, 192000, ErrorMessage = "Audio sample rate must be between 8000 and 192000.")] + public int? FfmpegAudioSamplerate { get; set; } + + [JsonPropertyName("ffmpeg_audio_bitrate")] + public string? FfmpegAudioBitrate { get; set; } + + [JsonPropertyName("ffmpeg_error_recovery")] + public bool? FfmpegErrorRecovery { get; set; } + + [JsonPropertyName("ffmpeg_faststart")] + public bool? FfmpegFaststart { get; set; } + + [JsonPropertyName("ffmpeg_progress")] + public bool? FfmpegProgress { get; set; } } } diff --git a/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbContext.cs b/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbContext.cs index c2b3b1d..94bcf4d 100644 --- a/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbContext.cs +++ b/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbContext.cs @@ -10,5 +10,6 @@ namespace TwitchArchive.Core.Persistence public DbSet StreamSessions { get; set; } = null!; public DbSet ArchiveJobs { get; set; } = null!; public DbSet StreamerStates { get; set; } = null!; + public DbSet UserCredentials { get; set; } = null!; } } diff --git a/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbInitializer.cs b/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbInitializer.cs new file mode 100644 index 0000000..3ef78a8 --- /dev/null +++ b/dotnet/src/TwitchArchive.Core/Persistence/ArchiveDbInitializer.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.EntityFrameworkCore; + +namespace TwitchArchive.Core.Persistence +{ + public static class ArchiveDbInitializer + { + public static void EnsureUserCredentialsTable(IDbContextFactory factory) + { + if (factory == null) throw new ArgumentNullException(nameof(factory)); + try + { + using var ctx = factory.CreateDbContext(); + var conn = ctx.Database.GetDbConnection(); + try { conn.Open(); } catch { /* ignore open errors */ } + using var cmd = conn.CreateCommand(); + // Create table if it doesn't exist (SQLite syntax) + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS UserCredentials ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + PasswordHash TEXT NOT NULL + );"; + cmd.ExecuteNonQuery(); + } + catch + { + // Initialization should not crash the app; log if needed + } + } + } +} diff --git a/dotnet/src/TwitchArchive.Core/Persistence/Models/UserCredential.cs b/dotnet/src/TwitchArchive.Core/Persistence/Models/UserCredential.cs new file mode 100644 index 0000000..8cb56de --- /dev/null +++ b/dotnet/src/TwitchArchive.Core/Persistence/Models/UserCredential.cs @@ -0,0 +1,8 @@ +namespace TwitchArchive.Core.Persistence.Models +{ + public class UserCredential + { + public int Id { get; set; } + public string PasswordHash { get; set; } = string.Empty; + } +} diff --git a/dotnet/src/TwitchArchive.Tests/EffectiveConfigDefaultsTests.cs b/dotnet/src/TwitchArchive.Tests/EffectiveConfigDefaultsTests.cs new file mode 100644 index 0000000..ac54d76 --- /dev/null +++ b/dotnet/src/TwitchArchive.Tests/EffectiveConfigDefaultsTests.cs @@ -0,0 +1,38 @@ +using Xunit; +using TwitchArchive.Core.Config; + +namespace TwitchArchive.Tests +{ + public class EffectiveConfigDefaultsTests + { + [Fact] + public void Merge_AppliesStreamerOverridesOverGlobalDefaults() + { + var global = new GlobalConfig + { + DefaultQuality = "best", + Defaults = new DefaultsSection + { + DownloadVOD = true, + MergeVideoChat = false, + VodTimeout = 300 + } + }; + + var streamer = new StreamerConfig + { + Username = "test", + DownloadVOD = false, + MergeVideoChat = true, + VodTimeout = 10 + }; + + var eff = EffectiveConfig.Merge(global, streamer); + + Assert.False(eff.Defaults.DownloadVOD); + Assert.True(eff.Defaults.MergeVideoChat); + Assert.Equal(10, eff.Defaults.VodTimeout); + Assert.Equal("best", eff.DefaultQuality); + } + } +} diff --git a/dotnet/src/TwitchArchive.Web/Pages/AddStreamer.razor b/dotnet/src/TwitchArchive.Web/Pages/AddStreamer.razor index e345a2b..e02e055 100644 --- a/dotnet/src/TwitchArchive.Web/Pages/AddStreamer.razor +++ b/dotnet/src/TwitchArchive.Web/Pages/AddStreamer.razor @@ -1,28 +1,207 @@ -@page "/config/new" +@page "/addstreamer" @inject TwitchArchive.Core.Config.IConfigurationService ConfigService @inject NavigationManager Nav

Add Streamer

-
- - + + +
+ +
-
- - +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + @if (showAdvanced) + { +
+

Advanced per-streamer defaults

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ } + +
+
- @code { private TwitchArchive.Core.Config.StreamerConfig model = new() { Enabled = true }; + private bool showAdvanced = false; + + // local fields for binding nullable/global override values + private bool downloadVOD = true; + private bool downloadCHAT = true; + private bool downloadLiveCHAT = true; + private bool mergeVideoChat = false; + private int? vodTimeout; + private bool uploadPreMergeVideo = true; + private bool uploadMergedVideo = true; + private bool uploadChatVideo = false; + private bool deleteFiles = false; + private bool onlyRaw = false; + private bool cleanRaw = true; + private int? hlsSegments; + private int? hlsSegmentsVOD; + private bool streamlinkTtvlol = false; + private string? ffmpegHwaccel; + private int? ffmpegThreads; + private string? ffmpegAudioCodec; + private int? ffmpegAudioSamplerate; + private string? ffmpegAudioBitrate; + private bool ffmpegErrorRecovery = true; + private bool ffmpegFaststart = true; + private bool ffmpegProgress = false; + + private void ToggleAdvanced() => showAdvanced = !showAdvanced; private void Save() { model.Username = model.Username?.Trim().ToLowerInvariant() ?? string.Empty; if (string.IsNullOrWhiteSpace(model.Username)) return; + + // map local fields into nullable model properties + model.DownloadVOD = downloadVOD; + model.DownloadCHAT = downloadCHAT; + model.DownloadLiveCHAT = downloadLiveCHAT; + model.MergeVideoChat = mergeVideoChat; + model.VodTimeout = vodTimeout; + model.UploadPreMergeVideo = uploadPreMergeVideo; + model.UploadMergedVideo = uploadMergedVideo; + model.UploadChatVideo = uploadChatVideo; + model.DeleteFiles = deleteFiles; + model.OnlyRaw = onlyRaw; + model.CleanRaw = cleanRaw; + model.HlsSegments = hlsSegments; + model.HlsSegmentsVOD = hlsSegmentsVOD; + model.StreamlinkTtvlol = streamlinkTtvlol; + model.FfmpegHwaccel = ffmpegHwaccel; + model.FfmpegThreads = ffmpegThreads; + model.FfmpegAudioCodec = ffmpegAudioCodec; + model.FfmpegAudioSamplerate = ffmpegAudioSamplerate; + model.FfmpegAudioBitrate = ffmpegAudioBitrate; + model.FfmpegErrorRecovery = ffmpegErrorRecovery; + model.FfmpegFaststart = ffmpegFaststart; + model.FfmpegProgress = ffmpegProgress; + ConfigService.SaveStreamer(model); Nav.NavigateTo($"/config/{model.Username}"); } diff --git a/dotnet/src/TwitchArchive.Web/Pages/AppSettings.razor b/dotnet/src/TwitchArchive.Web/Pages/AppSettings.razor index 9ccb7d3..a666c0b 100644 --- a/dotnet/src/TwitchArchive.Web/Pages/AppSettings.razor +++ b/dotnet/src/TwitchArchive.Web/Pages/AppSettings.razor @@ -1,35 +1,174 @@ @page "/settings" @using System.Text.Json +@inject TwitchArchive.Core.Config.IConfigurationService ConfigService @inject TwitchArchive.Web.Services.IAuthService Auth -

App Settings

+

Settings

@if (saved) {
Saved.
} - + +
+ + +
- +
- +
- +
- + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
-
-

Change Password

+
+ + @if (showDefaults) + { + + +
+

Defaults

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ } +
+ +
+ +
+ +

Change Password

@if (!string.IsNullOrEmpty(pwError)) {
@pwError
@@ -42,8 +181,9 @@
@code { - private TwitchArchive.Core.Config.AppSettings model = new(); + private TwitchArchive.Core.Config.GlobalConfig globalModel = new(); private bool saved = false; + private bool showDefaults = false; private string currentPw = string.Empty; private string newPw = string.Empty; private string confirmPw = string.Empty; @@ -56,29 +196,26 @@ private void Load() { - var file = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); try { - if (!File.Exists(file)) { model = new(); return; } - var txt = File.ReadAllText(file); - model = JsonSerializer.Deserialize(txt) ?? new TwitchArchive.Core.Config.AppSettings(); + globalModel = ConfigService.LoadGlobal() ?? new TwitchArchive.Core.Config.GlobalConfig(); } - catch { model = new(); } + catch { globalModel = new TwitchArchive.Core.Config.GlobalConfig(); } } - private void Save() + private void SaveGlobal() { - var file = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); try { - var txt = JsonSerializer.Serialize(model, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(file, txt); + ConfigService.SaveGlobal(globalModel); saved = true; - Auth.Refresh(); + // optionally notify auth or other services } catch { } } + private void ToggleDefaults() => showDefaults = !showDefaults; + private void ChangePassword() { pwError = string.Empty; @@ -87,6 +224,7 @@ if (newPw != confirmPw) { pwError = "Passwords do not match"; return; } var hash = BCrypt.Net.BCrypt.HashPassword(newPw); Auth.SetPasswordHash(hash); - pwError = string.Empty; + Auth.Refresh(); + saved = true; } } diff --git a/dotnet/src/TwitchArchive.Web/Pages/GlobalConfig.razor b/dotnet/src/TwitchArchive.Web/Pages/GlobalConfig.razor index 96ba67d..1ec6d41 100644 --- a/dotnet/src/TwitchArchive.Web/Pages/GlobalConfig.razor +++ b/dotnet/src/TwitchArchive.Web/Pages/GlobalConfig.razor @@ -1,45 +1,10 @@ @page "/config/global" -@inject TwitchArchive.Core.Config.IConfigurationService ConfigService - -

Global Configuration

- -@if (saved) -{ -
Saved.
-} - - -
- - -
-
- - -
-
- - -
-
- - -
- -
+@inject NavigationManager Nav @code { - private TwitchArchive.Core.Config.GlobalConfig model = new(); - private bool saved = false; - protected override void OnInitialized() { - model = ConfigService.LoadGlobal(); - } - - private void Save() - { - ConfigService.SaveGlobal(model); - saved = true; + // Consolidated settings are now at /settings + Nav.NavigateTo("/settings", true); } } diff --git a/dotnet/src/TwitchArchive.Web/Pages/Index.razor b/dotnet/src/TwitchArchive.Web/Pages/Index.razor index 9120fe2..e25b8cd 100644 --- a/dotnet/src/TwitchArchive.Web/Pages/Index.razor +++ b/dotnet/src/TwitchArchive.Web/Pages/Index.razor @@ -4,12 +4,18 @@

Dashboard

+@if (streamers.Count == 0) +{ +
No streamers configured. Add one on the Add Streamer page.
+} +
@foreach (var s in streamers) {
@s + Edit @(WorkerManager.IsRunning(s) ? "Live" : "Offline")
@@ -23,6 +29,12 @@ }
+@* Show global feedback when there are streamers but no recent sessions *@ +@if (streamers.Count > 0 && (lastStarts == null || lastStarts.Count == 0)) +{ +
No recent sessions found for configured streamers.
+} + @code { private List streamers = new(); private Dictionary lastStarts = new(); @@ -44,13 +56,33 @@ private void LoadStreamers() { - var cfgDir = Path.Combine(Environment.CurrentDirectory, "config", "streamers"); - if (Directory.Exists(cfgDir)) + // Try to find the config/streamers folder from the app content root and parent folders. + string? cfgDir = FindConfigStreamersFolder(); + if (!string.IsNullOrEmpty(cfgDir) && Directory.Exists(cfgDir)) { streamers = Directory.GetFiles(cfgDir, "*.json").Select(f => Path.GetFileNameWithoutExtension(f)).ToList(); } } + private string? FindConfigStreamersFolder() + { + // Prefer ContentRoot if available, fall back to Environment.CurrentDirectory. + var start = AppContext.BaseDirectory ?? Environment.CurrentDirectory; + var dir = new DirectoryInfo(start); + for (int i = 0; i < 6 && dir != null; i++) + { + var candidate = Path.Combine(dir.FullName, "config", "streamers"); + if (Directory.Exists(candidate)) return candidate; + dir = dir.Parent; + } + + // final attempt: repo-root relative (use project parent heuristics) + var alt = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "config", "streamers"); + try { alt = Path.GetFullPath(alt); } catch { } + if (Directory.Exists(alt)) return alt; + return null; + } + // Index reads from the singleton SessionCacheService; updates are pushed via the Updated event. private void Start(string u) { WorkerManager.StartWorker(u); } diff --git a/dotnet/src/TwitchArchive.Web/Pages/Media.razor b/dotnet/src/TwitchArchive.Web/Pages/Media.razor new file mode 100644 index 0000000..a42c78c --- /dev/null +++ b/dotnet/src/TwitchArchive.Web/Pages/Media.razor @@ -0,0 +1,59 @@ +@page "/media" +@inject TwitchArchive.Core.Config.IConfigurationService ConfigService + +

Media Library

+ +@if (string.IsNullOrWhiteSpace(archiveRoot)) +{ +
Archive root is not configured. Set it on the Settings page.
+} +else if (!Directory.Exists(archiveRoot)) +{ +
Archive root '@archiveRoot' does not exist on disk.
+} +else +{ + @if (entries.Count == 0) + { +
No media files found in '@archiveRoot'.
+ } + else + { + @foreach (var kv in entries) + { +
+

@kv.Key

+
    + @foreach (var f in kv.Value) + { +
  • @f.Name - @((f.Length/1024.0/1024.0).ToString("0.00")) MB - @f.LastWriteTime.ToLocalTime()
  • + } +
+
+ } + } +} + +@code { + private string? archiveRoot; + private Dictionary> entries = new(); + + protected override void OnInitialized() + { + var g = ConfigService.LoadGlobal(); + archiveRoot = g?.ArchiveRoot; + if (!string.IsNullOrWhiteSpace(archiveRoot) && Directory.Exists(archiveRoot)) + { + var di = new DirectoryInfo(archiveRoot); + foreach (var dir in di.GetDirectories()) + { + try + { + var files = dir.GetFiles("*.mp4").Concat(dir.GetFiles("*.mkv")).Concat(dir.GetFiles("*.ts")).Concat(dir.GetFiles("*.flv")).OrderByDescending(f => f.LastWriteTime).ToList(); + if (files.Count > 0) entries[dir.Name] = files; + } + catch { } + } + } + } +} diff --git a/dotnet/src/TwitchArchive.Web/Pages/StreamerConfig.razor b/dotnet/src/TwitchArchive.Web/Pages/StreamerConfig.razor index bf0c72a..1b58f8a 100644 --- a/dotnet/src/TwitchArchive.Web/Pages/StreamerConfig.razor +++ b/dotnet/src/TwitchArchive.Web/Pages/StreamerConfig.razor @@ -5,67 +5,331 @@

Streamer Config: @Username

+ + + @if (saved) + { +
Saved.
+ }
- - + + +
+ +
+ + Override +
- - Override - + + Override +
-
- - Override - +
+ +
-
- - +
+ + Override +
-
- - Override - +
+

Per-streamer overrides

+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + + + + + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + + + + + + + + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
+
+ + Override + +
- - + + +@* Confirmation modal *@ +@if (showConfirm) +{ + +} + @code { [Parameter] public string Username { get; set; } = string.Empty; private TwitchArchive.Core.Config.StreamerConfig model = new(); private TwitchArchive.Core.Config.GlobalConfig? global; + private bool saved = false; + private bool showConfirm = false; private bool overrideQuality = false; private bool overrideUpload = false; private bool overrideStreamlink = false; + private bool overrideDownloadVOD = false; + private bool overrideDownloadCHAT = false; + private bool overrideMergeVideoChat = false; + private bool overrideMergeChatLayout = false; + private bool overrideVodTimeout = false; + private bool overrideDeleteFiles = false; + private bool overrideHlsSegments = false; + private bool overrideFfmpegHwaccel = false; + private bool overrideFfmpegThreads = false; + private bool overrideFfmpegAudioBitrate = false; + private bool overrideDownloadLiveCHAT = false; + private bool overrideUploadPreMergeVideo = false; + private bool overrideUploadMergedVideo = false; + private bool overrideUploadChatVideo = false; + private bool overrideOnlyRaw = false; + private bool overrideCleanRaw = false; + private bool overrideHlsSegmentsVOD = false; + private bool overrideStreamlinkTtvlol = false; + private bool overrideFfmpegAudioCodec = false; + private bool overrideFfmpegAudioSamplerate = false; + private bool overrideFfmpegErrorRecovery = false; + private bool overrideFfmpegFaststart = false; + private bool overrideFfmpegProgress = false; + + // local values for nullable per-streamer settings (bind safely) + private bool downloadVODVal; + private bool downloadCHATVal; + private bool downloadLiveCHATVal; + private bool mergeVideoChatVal; + private string mergeChatLayoutVal = "side-by-side"; + private int? vodTimeoutVal; + private bool deleteFilesVal; + private int? hlsSegmentsVal; + private string ffmpegHwaccelVal = "auto"; + private int? ffmpegThreadsVal; + private string? ffmpegAudioBitrateVal; + private bool uploadPreMergeVideoVal; + private bool uploadMergedVideoVal; + private bool uploadChatVideoVal; + private bool onlyRawVal; + private bool cleanRawVal; + private int? hlsSegmentsVODVal; + private bool streamlinkTtvlolVal; + private string? ffmpegAudioCodecVal; + private int? ffmpegAudioSamplerateVal; + private bool ffmpegErrorRecoveryVal; + private bool ffmpegFaststartVal; + private bool ffmpegProgressVal; + private bool uploadToCloudVal; protected override void OnInitialized() { global = ConfigService.LoadGlobal(); var s = ConfigService.LoadStreamer(Username); if (s != null) model = s; + // initialize local values from model or global defaults + downloadVODVal = model.DownloadVOD ?? global?.Defaults.DownloadVOD ?? true; + downloadCHATVal = model.DownloadCHAT ?? global?.Defaults.DownloadCHAT ?? true; + downloadLiveCHATVal = model.DownloadLiveCHAT ?? global?.Defaults.DownloadLiveCHAT ?? true; + mergeVideoChatVal = model.MergeVideoChat ?? global?.Defaults.MergeVideoChat ?? false; + mergeChatLayoutVal = model.MergeChatLayout ?? global?.Defaults.MergeChatLayout ?? "side-by-side"; + vodTimeoutVal = model.VodTimeout ?? global?.Defaults.VodTimeout ?? 300; + deleteFilesVal = model.DeleteFiles ?? global?.Defaults.DeleteFiles ?? false; + hlsSegmentsVal = model.HlsSegments ?? global?.Defaults.HlsSegments ?? 3; + ffmpegHwaccelVal = model.FfmpegHwaccel ?? global?.Defaults.FfmpegHwaccel ?? "auto"; + ffmpegThreadsVal = model.FfmpegThreads ?? global?.Defaults.FfmpegThreads ?? 0; + ffmpegAudioBitrateVal = model.FfmpegAudioBitrate ?? global?.Defaults.FfmpegAudioBitrate ?? "192k"; + uploadPreMergeVideoVal = model.UploadPreMergeVideo ?? global?.Defaults.UploadPreMergeVideo ?? true; + uploadMergedVideoVal = model.UploadMergedVideo ?? global?.Defaults.UploadMergedVideo ?? true; + uploadChatVideoVal = model.UploadChatVideo ?? global?.Defaults.UploadChatVideo ?? false; + onlyRawVal = model.OnlyRaw ?? global?.Defaults.OnlyRaw ?? false; + cleanRawVal = model.CleanRaw ?? global?.Defaults.CleanRaw ?? true; + hlsSegmentsVODVal = model.HlsSegmentsVOD ?? global?.Defaults.HlsSegmentsVOD ?? 10; + streamlinkTtvlolVal = model.StreamlinkTtvlol ?? global?.Defaults.StreamlinkTtvlol ?? false; + ffmpegAudioCodecVal = model.FfmpegAudioCodec ?? global?.Defaults.FfmpegAudioCodec ?? "aac"; + ffmpegAudioSamplerateVal = model.FfmpegAudioSamplerate ?? global?.Defaults.FfmpegAudioSamplerate ?? 48000; + ffmpegErrorRecoveryVal = model.FfmpegErrorRecovery ?? global?.Defaults.FfmpegErrorRecovery ?? true; + ffmpegFaststartVal = model.FfmpegFaststart ?? global?.Defaults.FfmpegFaststart ?? true; + ffmpegProgressVal = model.FfmpegProgress ?? global?.Defaults.FfmpegProgress ?? false; + uploadToCloudVal = model.UploadToCloud ?? global?.UploadToCloud ?? false; } private void Save() { model.Username = Username; if (!overrideQuality) model.Quality = null; - if (!overrideUpload) model.UploadToCloud = null; - if (!overrideStreamlink) model.StreamlinkPath = null; + // Upload to cloud + model.UploadToCloud = overrideUpload ? uploadToCloudVal : (bool?)null; + // Streamlink path + model.StreamlinkPath = overrideStreamlink ? model.StreamlinkPath : null; + // Per-streamer values: map local values when overridden, otherwise clear + model.DownloadVOD = overrideDownloadVOD ? downloadVODVal : (bool?)null; + model.DownloadCHAT = overrideDownloadCHAT ? downloadCHATVal : (bool?)null; + model.DownloadLiveCHAT = overrideDownloadLiveCHAT ? downloadLiveCHATVal : (bool?)null; + model.MergeVideoChat = overrideMergeVideoChat ? mergeVideoChatVal : (bool?)null; + model.MergeChatLayout = overrideMergeChatLayout ? mergeChatLayoutVal : null; + model.VodTimeout = overrideVodTimeout ? vodTimeoutVal : (int?)null; + model.DeleteFiles = overrideDeleteFiles ? deleteFilesVal : (bool?)null; + model.HlsSegments = overrideHlsSegments ? hlsSegmentsVal : (int?)null; + model.FfmpegHwaccel = overrideFfmpegHwaccel ? ffmpegHwaccelVal : null; + model.FfmpegThreads = overrideFfmpegThreads ? ffmpegThreadsVal : (int?)null; + model.FfmpegAudioBitrate = overrideFfmpegAudioBitrate ? ffmpegAudioBitrateVal : null; + model.UploadPreMergeVideo = overrideUploadPreMergeVideo ? uploadPreMergeVideoVal : (bool?)null; + model.UploadMergedVideo = overrideUploadMergedVideo ? uploadMergedVideoVal : (bool?)null; + model.UploadChatVideo = overrideUploadChatVideo ? uploadChatVideoVal : (bool?)null; + model.OnlyRaw = overrideOnlyRaw ? onlyRawVal : (bool?)null; + model.CleanRaw = overrideCleanRaw ? cleanRawVal : (bool?)null; + model.HlsSegmentsVOD = overrideHlsSegmentsVOD ? hlsSegmentsVODVal : (int?)null; + model.StreamlinkTtvlol = overrideStreamlinkTtvlol ? streamlinkTtvlolVal : (bool?)null; + model.FfmpegAudioCodec = overrideFfmpegAudioCodec ? ffmpegAudioCodecVal : null; + model.FfmpegAudioSamplerate = overrideFfmpegAudioSamplerate ? ffmpegAudioSamplerateVal : (int?)null; + model.FfmpegErrorRecovery = overrideFfmpegErrorRecovery ? ffmpegErrorRecoveryVal : (bool?)null; + model.FfmpegFaststart = overrideFfmpegFaststart ? ffmpegFaststartVal : (bool?)null; + model.FfmpegProgress = overrideFfmpegProgress ? ffmpegProgressVal : (bool?)null; ConfigService.SaveStreamer(model); + saved = true; } - private void Delete() + private void ConfirmDelete() { - ConfigService.DeleteStreamer(Username); - Nav.NavigateTo("/"); + try + { + ConfigService.DeleteStreamer(Username); + showConfirm = false; + Nav.NavigateTo("/"); + } + catch + { + // ignore + } } } diff --git a/dotnet/src/TwitchArchive.Web/Pages/StreamerDetail.razor b/dotnet/src/TwitchArchive.Web/Pages/StreamerDetail.razor index bafa148..c9ac1e6 100644 --- a/dotnet/src/TwitchArchive.Web/Pages/StreamerDetail.razor +++ b/dotnet/src/TwitchArchive.Web/Pages/StreamerDetail.razor @@ -6,6 +6,7 @@
Status: @(WorkerManager.IsRunning(Username) ? "Live" : "Offline") + Edit Settings
diff --git a/dotnet/src/TwitchArchive.Web/Program.cs b/dotnet/src/TwitchArchive.Web/Program.cs index d655b3e..12ddccb 100644 --- a/dotnet/src/TwitchArchive.Web/Program.cs +++ b/dotnet/src/TwitchArchive.Web/Program.cs @@ -65,7 +65,8 @@ using (var scope = app.Services.CreateScope()) try { using var db = factory.CreateDbContext(); - db.Database.EnsureCreated(); + // Apply any pending EF migrations (creates/updates schema as needed) + db.Database.Migrate(); } catch { } } diff --git a/dotnet/src/TwitchArchive.Web/Services/AuthService.cs b/dotnet/src/TwitchArchive.Web/Services/AuthService.cs index 12f4e2f..be83fc0 100644 --- a/dotnet/src/TwitchArchive.Web/Services/AuthService.cs +++ b/dotnet/src/TwitchArchive.Web/Services/AuthService.cs @@ -1,18 +1,22 @@ using System; -using System.IO; -using System.Text.Json; -using TwitchArchive.Core.Config; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TwitchArchive.Core.Persistence; +using TwitchArchive.Core.Persistence.Models; namespace TwitchArchive.Web.Services { public class AuthService : IAuthService { - private readonly string _file; - private AppSettings _settings = new(); + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _log; + private string? _cachedHash; - public AuthService() + public AuthService(IDbContextFactory dbFactory, ILogger log) { - _file = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + _dbFactory = dbFactory ?? throw new ArgumentNullException(nameof(dbFactory)); + _log = log; Refresh(); } @@ -20,31 +24,52 @@ namespace TwitchArchive.Web.Services { try { - if (!File.Exists(_file)) { _settings = new AppSettings(); return; } - var txt = File.ReadAllText(_file); - _settings = JsonSerializer.Deserialize(txt) ?? new AppSettings(); + using var ctx = _dbFactory.CreateDbContext(); + var u = ctx.UserCredentials.AsNoTracking().FirstOrDefault(); + _cachedHash = u?.PasswordHash; + } + catch (Exception ex) + { + _log?.LogWarning(ex, "Failed to read password from database"); + _cachedHash = null; } - catch { _settings = new AppSettings(); } } public bool ValidatePassword(string plain) { - if (string.IsNullOrWhiteSpace(_settings.PasswordHash)) return true; - try { return BCrypt.Net.BCrypt.Verify(plain ?? string.Empty, _settings.PasswordHash); } - catch { return false; } + // If no password configured, allow access (default open) + if (string.IsNullOrWhiteSpace(_cachedHash)) return true; + try { return BCrypt.Net.BCrypt.Verify(plain ?? string.Empty, _cachedHash); } + catch (Exception ex) + { + _log?.LogWarning(ex, "Password verification failed"); + return false; + } } public void SetPasswordHash(string hash) { try { - if (string.IsNullOrWhiteSpace(_file)) return; - _settings.PasswordHash = hash; - var txt = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_file, txt); - Refresh(); + using var ctx = _dbFactory.CreateDbContext(); + var existing = ctx.UserCredentials.FirstOrDefault(); + if (existing == null) + { + existing = new UserCredential { PasswordHash = hash }; + ctx.UserCredentials.Add(existing); + } + else + { + existing.PasswordHash = hash; + ctx.UserCredentials.Update(existing); + } + ctx.SaveChanges(); + _cachedHash = hash; + } + catch (Exception ex) + { + _log?.LogError(ex, "Failed to save password to database"); } - catch { } } } } diff --git a/dotnet/src/TwitchArchive.Web/Shared/MainLayout.razor b/dotnet/src/TwitchArchive.Web/Shared/MainLayout.razor index dd8a426..cf6d00c 100644 --- a/dotnet/src/TwitchArchive.Web/Shared/MainLayout.razor +++ b/dotnet/src/TwitchArchive.Web/Shared/MainLayout.razor @@ -1,17 +1,27 @@ @inherits LayoutComponentBase
- -

Twitch Archive

+
+ +

Twitch Archive

+ +
- @Body +
@Body
diff --git a/dotnet/src/TwitchArchive.Web/archive.db-shm b/dotnet/src/TwitchArchive.Web/archive.db-shm index 08698a3..b4164f6 100644 Binary files a/dotnet/src/TwitchArchive.Web/archive.db-shm and b/dotnet/src/TwitchArchive.Web/archive.db-shm differ diff --git a/dotnet/src/TwitchArchive.Web/archive.db-wal b/dotnet/src/TwitchArchive.Web/archive.db-wal index f7800fa..4c417b6 100644 Binary files a/dotnet/src/TwitchArchive.Web/archive.db-wal and b/dotnet/src/TwitchArchive.Web/archive.db-wal differ diff --git a/dotnet/src/TwitchArchive.Web/wwwroot/css/app.css b/dotnet/src/TwitchArchive.Web/wwwroot/css/app.css index ad8771f..5c247f8 100644 --- a/dotnet/src/TwitchArchive.Web/wwwroot/css/app.css +++ b/dotnet/src/TwitchArchive.Web/wwwroot/css/app.css @@ -1,16 +1,34 @@ /* App layout styles for Twitch Archive */ -.page { display:flex; height:100vh; } -.sidebar { width:220px; flex-shrink:0; background:#1e1e2e; color:#cdd6f4; overflow-y:auto; } -.sidebar.collapsed { display:none; } +:root{ + --bg:#0f1720; --panel:#111322; --muted:#9aa4c5; --accent:#7dd3fc; --accent-2:#89b4fa; --card:#0b1220; +} +html,body { height:100%; margin:0; font-family:Segoe UI, Roboto, -apple-system, sans-serif; background:var(--bg); color:#e6eef8; } +.page { display:flex; min-height:100vh; } +.sidebar { width:220px; flex-shrink:0; background:var(--panel); color:var(--muted); overflow-y:auto; padding:1rem 0; box-shadow:2px 0 8px rgba(6,6,10,0.6); } +.sidebar.collapsed { width:66px; } +.brand { font-weight:600; padding:0 1rem 0.8rem 1rem; color:#fff; } +.nav-list { list-style:none; margin:0; padding:0; } +.nav-list li { margin:0.2rem 0; } +.nav-link { display:block; padding:0.6rem 1rem; color:var(--muted); text-decoration:none; border-left:4px solid transparent; } +.nav-link.active, .nav-link:hover { background:linear-gradient(90deg, rgba(255,255,255,0.02), transparent); color:#fff; border-left-color:var(--accent-2); } +.topbar { display:flex; align-items:center; background:var(--panel); padding:0.6rem 1rem; box-shadow:0 2px 6px rgba(0,0,0,0.4); } +.topbar .topbar-inner { display:flex; align-items:center; width:100%; } +.hamburger { font-size:1.2rem; margin-right:1rem; background:transparent; border:none; color:inherit; cursor:pointer; } +.title { margin:0; font-size:1.05rem; flex:1; } +.top-actions .action { color:var(--muted); text-decoration:none; margin-left:1rem; } .main { flex:1; overflow-y:auto; padding:1.5rem; } -.nav-link { display:block; padding:0.6rem 1rem; color:#cdd6f4; text-decoration:none; } -.nav-link.active { background:#313244; border-left:3px solid #89b4fa; } -.topbar { display:none; background:#111; color:#fff; padding:0.6rem 1rem; align-items:center; } -.topbar .hamburger { font-size:1.2rem; margin-right:1rem; background:transparent; border:none; color:inherit; } +.container { max-width:1100px; margin:0 auto; } + +/* Cards and forms */ +.card { background:var(--card); padding:1rem; border-radius:8px; box-shadow:0 2px 8px rgba(2,6,23,0.6); margin-bottom:1rem; } +.card-header { display:flex; justify-content:space-between; align-items:center; font-weight:600; } +.card-body { margin-top:0.5rem; color:var(--muted); } +button { background:var(--accent); border:none; color:#002; padding:0.45rem 0.8rem; border-radius:6px; cursor:pointer; } +button:hover { opacity:0.95; } +input[type=password], input[type=text], input[type=number], .input, InputText { padding:0.5rem; border-radius:6px; border:1px solid rgba(255,255,255,0.06); background:transparent; color:inherit; } + @media(max-width:768px) { .sidebar { display:none; } .topbar { display:flex; } } -@media(min-width:769px) { - .topbar { display:none; } -} +