commit e2ad15fa640fb1567a7a350e368fb6f6ebb183db Author: felis Date: Wed Jun 3 20:24:57 2026 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Components/App.razor b/Components/App.razor new file mode 100644 index 0000000..f249382 --- /dev/null +++ b/Components/App.razor @@ -0,0 +1,25 @@ + + + + + + + + + 洛星瓷歌单 + + + + + + + + + + + + + + + + diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..9d3a290 --- /dev/null +++ b/Components/Layout/MainLayout.razor @@ -0,0 +1,99 @@ +@inherits LayoutComponentBase + + + + + + + + + + 洛星瓷歌单 + + + + + 搜歌 + + + + + 管理 + + + 退出 + + + + + 管理登录 + + + + + + + 搜歌 + + + 管理 + 退出 + + + 管理登录 + + + + + + +
+
+
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ +@code { + private readonly MudTheme _theme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#5b8ecf", + Secondary = "#9fd8ec", + Tertiary = "#d8edf7", + AppbarBackground = "rgba(248, 252, 255, 0.78)", + AppbarText = "#214a72", + Background = "#f6fbff", + Surface = "rgba(255, 255, 255, 0.86)", + DrawerBackground = "#f8fcff", + TextPrimary = "#18344f", + TextSecondary = "#55718c", + Info = "#6baed6", + Success = "#4ea38c", + Warning = "#d89a42", + Error = "#c9566a" + }, + LayoutProperties = new LayoutProperties + { + DefaultBorderRadius = "8px" + }, + Typography = new Typography + { + Default = new DefaultTypography + { + FontFamily = ["Inter", "Noto Sans SC", "Microsoft YaHei", "sans-serif"] + }, + H1 = new H1Typography { FontWeight = "700" }, + H2 = new H2Typography { FontWeight = "700" }, + H3 = new H3Typography { FontWeight = "700" } + } + }; +} diff --git a/Components/Layout/MainLayout.razor.css b/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..cad4ada --- /dev/null +++ b/Components/Layout/MainLayout.razor.css @@ -0,0 +1,29 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } +::deep .brand-button { + font-weight: 700; + letter-spacing: 0; +} + +::deep .porcelain-appbar { + border-bottom: 1px solid rgba(121, 169, 212, 0.26); + backdrop-filter: blur(18px); +} diff --git a/Components/Layout/ReconnectModal.razor b/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..e740b0c --- /dev/null +++ b/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/Components/Layout/ReconnectModal.razor.css b/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/Components/Layout/ReconnectModal.razor.js b/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..a44de78 --- /dev/null +++ b/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/Components/Pages/Admin/Index.razor b/Components/Pages/Admin/Index.razor new file mode 100644 index 0000000..34f6c51 --- /dev/null +++ b/Components/Pages/Admin/Index.razor @@ -0,0 +1,284 @@ +@page "/admin" +@attribute [Authorize] +@inject SongCatalogService Catalog +@inject SiteSettingsService SiteSettings +@inject ISnackbar Snackbar + +歌单管理 + +
+
+
+ 歌单管理 + 添加歌曲、隐藏曲目、清理标签和调整点歌文案。 +
+ + 退出 + +
+ + + + +
+ 歌曲 + @_songs.Count 首 +
+ +
+ + + 添加 + +
+ + @if (_songs.Count == 0) + { + 还没有歌曲。之后可以从这里手动添加,或直接导入数据库。 + } + else + { +
+ @foreach (var song in _songs) + { + + + +
+ @foreach (var tag in song.Tags) + { + @tag + } +
+
+ + 保存 + + + 删除 + +
+
+ } +
+ } +
+
+ + + + 站点设置 + + + 保存设置 + + + + +
+ 标签 + @_tags.Count 个 +
+ + @if (_tags.Count == 0) + { + 暂无标签。 + } + else + { +
+ @foreach (var tag in _tags) + { + + + +
+ + 改名 + + + 合并 + + +
+
+ } +
+ } +
+
+
+
+ +@code { + private readonly List _songs = []; + private readonly List _tags = []; + private string _newSongTitle = string.Empty; + private string _requestTemplate = "点歌《{title}》"; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + var songs = await Catalog.GetAdminSongsAsync(); + var tags = await Catalog.GetAdminTagsAsync(); + _requestTemplate = await SiteSettings.GetRequestTemplateAsync(); + + _songs.Clear(); + _songs.AddRange(songs.Select(AdminSongState.From)); + _tags.Clear(); + _tags.AddRange(tags.Select(AdminTagState.From)); + } + + private async Task CreateSong() + { + await RunAdminAction(async () => + { + await Catalog.CreateSongAsync(_newSongTitle); + _newSongTitle = string.Empty; + Snackbar.Add("歌曲已添加", Severity.Success); + }); + } + + private async Task SaveSong(AdminSongState song) + { + await RunAdminAction(async () => + { + await Catalog.UpdateSongAsync(song.Id, song.Title, song.IsHidden); + Snackbar.Add("歌曲已保存", Severity.Success); + }); + } + + private async Task DeleteSong(AdminSongState song) + { + await RunAdminAction(async () => + { + await Catalog.DeleteSongAsync(song.Id); + Snackbar.Add("歌曲已删除", Severity.Success); + }); + } + + private async Task SaveRequestTemplate() + { + await RunAdminAction(async () => + { + await SiteSettings.SaveRequestTemplateAsync(_requestTemplate); + Snackbar.Add("设置已保存", Severity.Success); + }, reload: false); + } + + private async Task RenameTag(AdminTagState tag) + { + await RunAdminAction(async () => + { + await Catalog.RenameTagAsync(tag.Id, tag.Name); + Snackbar.Add("标签已更新", Severity.Success); + }); + } + + private async Task MergeTag(AdminTagState tag) + { + await RunAdminAction(async () => + { + await Catalog.MergeTagAsync(tag.Id, tag.MergeTarget); + Snackbar.Add("标签已合并", Severity.Success); + }); + } + + private async Task DeleteTag(AdminTagState tag) + { + await RunAdminAction(async () => + { + await Catalog.DeleteTagAsync(tag.Id); + Snackbar.Add("标签已删除", Severity.Success); + }); + } + + private async Task RunAdminAction(Func action, bool reload = true) + { + try + { + await action(); + if (reload) + { + await LoadAsync(); + } + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Warning); + } + } + + private sealed class AdminSongState + { + public int Id { get; init; } + public string Title { get; set; } = string.Empty; + public bool IsHidden { get; set; } + public IReadOnlyList Tags { get; init; } = []; + + public static AdminSongState From(AdminSongItem item) + { + return new AdminSongState + { + Id = item.Id, + Title = item.Title, + IsHidden = item.IsHidden, + Tags = item.Tags + }; + } + } + + private sealed class AdminTagState + { + public int Id { get; init; } + public string Name { get; set; } = string.Empty; + public string MergeTarget { get; set; } = string.Empty; + public int SongCount { get; init; } + + public static AdminTagState From(AdminTagItem item) + { + return new AdminTagState + { + Id = item.Id, + Name = item.Name, + SongCount = item.SongCount + }; + } + } +} diff --git a/Components/Pages/Admin/Login.razor b/Components/Pages/Admin/Login.razor new file mode 100644 index 0000000..b557344 --- /dev/null +++ b/Components/Pages/Admin/Login.razor @@ -0,0 +1,41 @@ +@page "/admin/login" +@inject NavigationManager Navigation + +管理登录 + + + +@code { + [SupplyParameterFromQuery(Name = "error")] + public string? Error { get; set; } + + [SupplyParameterFromQuery(Name = "returnUrl")] + public string? ReturnUrl { get; set; } + + private string ResolvedReturnUrl => string.IsNullOrWhiteSpace(ReturnUrl) ? "/admin" : ReturnUrl; +} diff --git a/Components/Pages/Error.razor b/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor new file mode 100644 index 0000000..72dfeeb --- /dev/null +++ b/Components/Pages/Home.razor @@ -0,0 +1,201 @@ +@page "/" +@inject SongCatalogService Catalog +@inject SiteSettingsService SiteSettings +@inject IJSRuntime Js +@inject ISnackbar Snackbar + +洛星瓷歌单 + +
+
+
+ 洛星瓷歌单 + 快速查找会唱的歌,复制点歌文案,也可以给歌曲补充标签。 +
+ + + 角色图占位 + +
+ + + + + @if (_selectedTag is not null) + { +
+ + 标签:@_selectedTag + +
+ } + + @if (_allTags.Count > 0) + { +
+ @foreach (var tag in _allTags.Take(32)) + { + + @tag + + } +
+ } +
+ + @if (_isLoading) + { +
+ +
+ } + else if (_songs.Count == 0) + { + + + 歌单还是空的 + 之后导入歌曲后,这里会显示可搜索的曲目。 + + } + else + { +
+ @foreach (var song in _songs) + { + +
+ @song.Title +
+ @foreach (var tag in song.Tags) + { + + @tag + + } +
+
+ +
+ + 复制点歌 + +
+ +
+ + +
+
+ } +
+ } +
+ +@code { + private readonly List _songs = []; + private readonly List _allTags = []; + private string _search = string.Empty; + private string? _selectedTag; + private string _requestTemplate = "点歌《{title}》"; + private bool _isLoading = true; + + protected override async Task OnInitializedAsync() + { + _requestTemplate = await SiteSettings.GetRequestTemplateAsync(); + await LoadAsync(); + } + + private async Task OnSearchChanged(string value) + { + _search = value; + await LoadAsync(); + } + + private async Task SelectTag(string tag) + { + _selectedTag = _selectedTag == tag ? null : tag; + await LoadAsync(); + } + + private async Task ClearTagFilter() + { + _selectedTag = null; + await LoadAsync(); + } + + private async Task LoadAsync() + { + _isLoading = true; + var songs = await Catalog.SearchSongsAsync(_search, _selectedTag); + var tags = await Catalog.GetVisibleTagsAsync(); + + _songs.Clear(); + _songs.AddRange(songs.Select(SongCardState.From)); + _allTags.Clear(); + _allTags.AddRange(tags); + _isLoading = false; + } + + private async Task CopyRequest(string title) + { + var request = SiteSettingsService.FormatRequest(_requestTemplate, title); + await Js.InvokeVoidAsync("navigator.clipboard.writeText", request); + Snackbar.Add($"已复制:{request}", Severity.Success); + } + + private async Task AddTag(SongCardState song) + { + try + { + await Catalog.AddViewerTagAsync(song.Id, song.NewTag); + Snackbar.Add("标签已添加", Severity.Success); + await LoadAsync(); + } + catch (InvalidOperationException ex) + { + Snackbar.Add(ex.Message, Severity.Warning); + } + } + + private sealed class SongCardState + { + public int Id { get; init; } + public string Title { get; init; } = string.Empty; + public IReadOnlyList Tags { get; init; } = []; + public string NewTag { get; set; } = string.Empty; + + public static SongCardState From(SongListItem item) + { + return new SongCardState + { + Id = item.Id, + Title = item.Title, + Tags = item.Tags + }; + } + } +} diff --git a/Components/Pages/NotFound.razor b/Components/Pages/NotFound.razor new file mode 100644 index 0000000..934bb87 --- /dev/null +++ b/Components/Pages/NotFound.razor @@ -0,0 +1,8 @@ +@page "/not-found" +@layout MainLayout + + + + 没有找到这个页面 + 回到歌单 + diff --git a/Components/Routes.razor b/Components/Routes.razor new file mode 100644 index 0000000..23099f3 --- /dev/null +++ b/Components/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Components/Shared/RedirectToLogin.razor b/Components/Shared/RedirectToLogin.razor new file mode 100644 index 0000000..3d5edae --- /dev/null +++ b/Components/Shared/RedirectToLogin.razor @@ -0,0 +1,9 @@ +@inject NavigationManager Navigation + +@code { + protected override void OnInitialized() + { + var returnUrl = Uri.EscapeDataString(Navigation.ToBaseRelativePath(Navigation.Uri)); + Navigation.NavigateTo($"/admin/login?returnUrl=/{returnUrl}", forceLoad: true); + } +} diff --git a/Components/_Imports.razor b/Components/_Imports.razor new file mode 100644 index 0000000..da3745f --- /dev/null +++ b/Components/_Imports.razor @@ -0,0 +1,16 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using MudBlazor +@using lxc_songlist +@using lxc_songlist.Components +@using lxc_songlist.Components.Layout +@using lxc_songlist.Components.Shared +@using lxc_songlist.Services diff --git a/Data/SongListDbContext.cs b/Data/SongListDbContext.cs new file mode 100644 index 0000000..6f43739 --- /dev/null +++ b/Data/SongListDbContext.cs @@ -0,0 +1,51 @@ +using lxc_songlist.Models; +using Microsoft.EntityFrameworkCore; + +namespace lxc_songlist.Data; + +public sealed class SongListDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Songs => Set(); + public DbSet Tags => Set(); + public DbSet SongTags => Set(); + public DbSet SiteSettings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.Property(song => song.Title).HasMaxLength(200).IsRequired(); + entity.Property(song => song.NormalizedTitle).HasMaxLength(200).IsRequired(); + entity.HasIndex(song => song.NormalizedTitle).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.Property(tag => tag.Name).HasMaxLength(50).IsRequired(); + entity.Property(tag => tag.NormalizedName).HasMaxLength(50).IsRequired(); + entity.HasIndex(tag => tag.NormalizedName).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(songTag => new { songTag.SongId, songTag.TagId }); + entity + .HasOne(songTag => songTag.Song) + .WithMany(song => song.SongTags) + .HasForeignKey(songTag => songTag.SongId) + .OnDelete(DeleteBehavior.Cascade); + entity + .HasOne(songTag => songTag.Tag) + .WithMany(tag => tag.SongTags) + .HasForeignKey(songTag => songTag.TagId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(setting => setting.Key); + entity.Property(setting => setting.Key).HasMaxLength(80); + entity.Property(setting => setting.Value).HasMaxLength(500); + }); + } +} diff --git a/Migrations/20260604022832_InitialCreate.Designer.cs b/Migrations/20260604022832_InitialCreate.Designer.cs new file mode 100644 index 0000000..c6e2a77 --- /dev/null +++ b/Migrations/20260604022832_InitialCreate.Designer.cs @@ -0,0 +1,163 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using lxc_songlist.Data; + +#nullable disable + +namespace lxc_songlist.Migrations +{ + [DbContext(typeof(SongListDbContext))] + [Migration("20260604022832_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("lxc_songlist.Models.SiteSetting", b => + { + b.Property("Key") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("SiteSettings"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Song", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("lxc_songlist.Models.SongTag", b => + { + b.Property("SongId") + .HasColumnType("integer"); + + b.Property("TagId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByViewer") + .HasColumnType("boolean"); + + b.HasKey("SongId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("SongTags"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("lxc_songlist.Models.SongTag", b => + { + b.HasOne("lxc_songlist.Models.Song", "Song") + .WithMany("SongTags") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("lxc_songlist.Models.Tag", "Tag") + .WithMany("SongTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Song", b => + { + b.Navigation("SongTags"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Tag", b => + { + b.Navigation("SongTags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260604022832_InitialCreate.cs b/Migrations/20260604022832_InitialCreate.cs new file mode 100644 index 0000000..3087d05 --- /dev/null +++ b/Migrations/20260604022832_InitialCreate.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace lxc_songlist.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SiteSettings", + columns: table => new + { + Key = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Value = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SiteSettings", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "Songs", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + NormalizedTitle = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsHidden = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Songs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + NormalizedName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SongTags", + columns: table => new + { + SongId = table.Column(type: "integer", nullable: false), + TagId = table.Column(type: "integer", nullable: false), + CreatedByViewer = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SongTags", x => new { x.SongId, x.TagId }); + table.ForeignKey( + name: "FK_SongTags_Songs_SongId", + column: x => x.SongId, + principalTable: "Songs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SongTags_Tags_TagId", + column: x => x.TagId, + principalTable: "Tags", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Songs_NormalizedTitle", + table: "Songs", + column: "NormalizedTitle", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SongTags_TagId", + table: "SongTags", + column: "TagId"); + + migrationBuilder.CreateIndex( + name: "IX_Tags_NormalizedName", + table: "Tags", + column: "NormalizedName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SiteSettings"); + + migrationBuilder.DropTable( + name: "SongTags"); + + migrationBuilder.DropTable( + name: "Songs"); + + migrationBuilder.DropTable( + name: "Tags"); + } + } +} diff --git a/Migrations/SongListDbContextModelSnapshot.cs b/Migrations/SongListDbContextModelSnapshot.cs new file mode 100644 index 0000000..2c12717 --- /dev/null +++ b/Migrations/SongListDbContextModelSnapshot.cs @@ -0,0 +1,160 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using lxc_songlist.Data; + +#nullable disable + +namespace lxc_songlist.Migrations +{ + [DbContext(typeof(SongListDbContext))] + partial class SongListDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("lxc_songlist.Models.SiteSetting", b => + { + b.Property("Key") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Key"); + + b.ToTable("SiteSettings"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Song", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Songs"); + }); + + modelBuilder.Entity("lxc_songlist.Models.SongTag", b => + { + b.Property("SongId") + .HasColumnType("integer"); + + b.Property("TagId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByViewer") + .HasColumnType("boolean"); + + b.HasKey("SongId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("SongTags"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("lxc_songlist.Models.SongTag", b => + { + b.HasOne("lxc_songlist.Models.Song", "Song") + .WithMany("SongTags") + .HasForeignKey("SongId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("lxc_songlist.Models.Tag", "Tag") + .WithMany("SongTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Song"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Song", b => + { + b.Navigation("SongTags"); + }); + + modelBuilder.Entity("lxc_songlist.Models.Tag", b => + { + b.Navigation("SongTags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Models/SiteSetting.cs b/Models/SiteSetting.cs new file mode 100644 index 0000000..38aa31c --- /dev/null +++ b/Models/SiteSetting.cs @@ -0,0 +1,8 @@ +namespace lxc_songlist.Models; + +public sealed class SiteSetting +{ + public string Key { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/Models/Song.cs b/Models/Song.cs new file mode 100644 index 0000000..b052bdf --- /dev/null +++ b/Models/Song.cs @@ -0,0 +1,13 @@ +namespace lxc_songlist.Models; + +public sealed class Song +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string NormalizedTitle { get; set; } = string.Empty; + public bool IsHidden { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection SongTags { get; set; } = new List(); +} diff --git a/Models/SongTag.cs b/Models/SongTag.cs new file mode 100644 index 0000000..198cca0 --- /dev/null +++ b/Models/SongTag.cs @@ -0,0 +1,13 @@ +namespace lxc_songlist.Models; + +public sealed class SongTag +{ + public int SongId { get; set; } + public Song Song { get; set; } = null!; + + public int TagId { get; set; } + public Tag Tag { get; set; } = null!; + + public bool CreatedByViewer { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/Models/Tag.cs b/Models/Tag.cs new file mode 100644 index 0000000..7489dcb --- /dev/null +++ b/Models/Tag.cs @@ -0,0 +1,11 @@ +namespace lxc_songlist.Models; + +public sealed class Tag +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string NormalizedName { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection SongTags { get; set; } = new List(); +} diff --git a/Options/AdminAuthOptions.cs b/Options/AdminAuthOptions.cs new file mode 100644 index 0000000..1f92a82 --- /dev/null +++ b/Options/AdminAuthOptions.cs @@ -0,0 +1,7 @@ +namespace lxc_songlist.Services; + +public sealed class AdminAuthOptions +{ + public string PasswordHash { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} diff --git a/Options/SiteOptions.cs b/Options/SiteOptions.cs new file mode 100644 index 0000000..94f39f7 --- /dev/null +++ b/Options/SiteOptions.cs @@ -0,0 +1,6 @@ +namespace lxc_songlist.Services; + +public sealed class SiteOptions +{ + public string RequestTemplate { get; set; } = "点歌《{title}》"; +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..22a5db2 --- /dev/null +++ b/Program.cs @@ -0,0 +1,105 @@ +using System.Security.Claims; +using lxc_songlist.Components; +using lxc_songlist.Data; +using lxc_songlist.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using MudBlazor.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/admin/login"; + options.AccessDeniedPath = "/admin/login"; + options.Cookie.Name = "lxc-songlist-admin"; + options.SlidingExpiration = true; + }); +builder.Services.AddAuthorization(); +builder.Services.AddMudServices(); + +builder.Services.Configure(builder.Configuration.GetSection("Admin")); +builder.Services.Configure(builder.Configuration.GetSection("Site")); + +builder.Services.AddDbContextFactory(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapPost("/admin/sign-in", async ( + HttpContext httpContext, + AdminPasswordService passwordService) => + { + var form = await httpContext.Request.ReadFormAsync(); + var password = form["password"].ToString(); + var returnUrl = form["returnUrl"].ToString(); + + if (!passwordService.Verify(password)) + { + return Results.Redirect("/admin/login?error=1"); + } + + var claims = new[] + { + new Claim(ClaimTypes.Name, "admin"), + new Claim(ClaimTypes.Role, "Admin") + }; + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await httpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + principal, + new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(14) + }); + + return Results.Redirect(IsLocalUrl(returnUrl) ? returnUrl : "/admin"); + }) + .DisableAntiforgery(); + +app.MapGet("/admin/logout", async (HttpContext httpContext) => + { + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect("/"); + }) + .RequireAuthorization(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); + +static bool IsLocalUrl(string? url) +{ + return !string.IsNullOrWhiteSpace(url) + && Uri.TryCreate(url, UriKind.Relative, out _) + && url[0] == '/' + && (url.Length == 1 || (url[1] != '/' && url[1] != '\\')); +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..e158251 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:0", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:0;http://localhost:0", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/README.md b/README.md new file mode 100644 index 0000000..04ed6c2 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# 洛星瓷歌单 + +Blazor Server-style song list site for 洛星瓷, built with .NET 10, MudBlazor, EF Core, and PostgreSQL. + +## Local Setup + +The sandbox home directory is read-only for .NET first-run files, so local commands here use: + +```bash +DOTNET_CLI_HOME=/tmp/dotnet-home +``` + +Database created during implementation: + +- database: `lxc_songlist` +- role: `lxc_songlist` +- development password: `change-me` + +Apply migrations: + +```bash +DOTNET_CLI_HOME=/tmp/dotnet-home dotnet ef database update +``` + +Run locally: + +```bash +DOTNET_CLI_HOME=/tmp/dotnet-home ASPNETCORE_ENVIRONMENT=Development dotnet run --urls http://127.0.0.1:5000 +``` + +Development admin login: + +- URL: `/admin/login` +- password: `lxc-admin` + +For production, set `Admin:PasswordHash` or `Admin:Password` and `ConnectionStrings:DefaultConnection` outside source control. + +## Current Features + +- Chinese-first public song search page. +- Empty PostgreSQL-backed catalog ready for import. +- Song title and tag search. +- Copyable request text, defaulting to `点歌《{title}》`. +- Instant viewer-added tags. +- Single-admin song and tag management. +- Star porcelain MudBlazor theme with responsive desktop/mobile layout. diff --git a/Services/AdminPasswordService.cs b/Services/AdminPasswordService.cs new file mode 100644 index 0000000..b95f7c1 --- /dev/null +++ b/Services/AdminPasswordService.cs @@ -0,0 +1,54 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; + +namespace lxc_songlist.Services; + +public sealed class AdminPasswordService(IOptions options) +{ + private const int SaltBytes = 16; + private const int HashBytes = 32; + private const int Iterations = 210_000; + + public bool Verify(string password) + { + var configuredHash = options.Value.PasswordHash; + if (!string.IsNullOrWhiteSpace(configuredHash)) + { + return VerifyHash(password, configuredHash); + } + + var configuredPassword = options.Value.Password; + return !string.IsNullOrEmpty(configuredPassword) + && CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(password), + System.Text.Encoding.UTF8.GetBytes(configuredPassword)); + } + + public static string CreateHash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltBytes); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashBytes); + return $"pbkdf2${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; + } + + private static bool VerifyHash(string password, string storedHash) + { + var parts = storedHash.Split('$'); + if (parts.Length != 4 || parts[0] != "pbkdf2" || !int.TryParse(parts[1], out var iterations)) + { + return false; + } + + try + { + var salt = Convert.FromBase64String(parts[2]); + var expectedHash = Convert.FromBase64String(parts[3]); + var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, expectedHash.Length); + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } + catch (FormatException) + { + return false; + } + } +} diff --git a/Services/SiteSettingsService.cs b/Services/SiteSettingsService.cs new file mode 100644 index 0000000..2cfe3a6 --- /dev/null +++ b/Services/SiteSettingsService.cs @@ -0,0 +1,53 @@ +using lxc_songlist.Data; +using lxc_songlist.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace lxc_songlist.Services; + +public sealed class SiteSettingsService( + IDbContextFactory dbFactory, + IOptions options) +{ + public const string RequestTemplateKey = "request_template"; + + public async Task GetRequestTemplateAsync(CancellationToken cancellationToken = default) + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var setting = await db.SiteSettings.FindAsync([RequestTemplateKey], cancellationToken); + return string.IsNullOrWhiteSpace(setting?.Value) + ? options.Value.RequestTemplate + : setting.Value; + } + + public async Task SaveRequestTemplateAsync(string template, CancellationToken cancellationToken = default) + { + var cleanTemplate = string.IsNullOrWhiteSpace(template) + ? options.Value.RequestTemplate + : template.Trim(); + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var setting = await db.SiteSettings.FindAsync([RequestTemplateKey], cancellationToken); + if (setting is null) + { + db.SiteSettings.Add(new SiteSetting + { + Key = RequestTemplateKey, + Value = cleanTemplate, + UpdatedAt = DateTimeOffset.UtcNow + }); + } + else + { + setting.Value = cleanTemplate; + setting.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(cancellationToken); + } + + public static string FormatRequest(string template, string title) + { + return template.Replace("{title}", title, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Services/SongCatalogService.cs b/Services/SongCatalogService.cs new file mode 100644 index 0000000..3b0b3a5 --- /dev/null +++ b/Services/SongCatalogService.cs @@ -0,0 +1,299 @@ +using lxc_songlist.Data; +using lxc_songlist.Models; +using Microsoft.EntityFrameworkCore; + +namespace lxc_songlist.Services; + +public sealed class SongCatalogService(IDbContextFactory dbFactory) +{ + public async Task> SearchSongsAsync( + string? search, + string? selectedTag, + CancellationToken cancellationToken = default) + { + var queryText = TextNormalizer.Key(search ?? string.Empty); + var tagKey = TextNormalizer.Key(selectedTag ?? string.Empty); + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var query = db.Songs + .AsNoTracking() + .Include(song => song.SongTags) + .ThenInclude(songTag => songTag.Tag) + .Where(song => !song.IsHidden); + + if (!string.IsNullOrWhiteSpace(queryText)) + { + query = query.Where(song => + song.NormalizedTitle.Contains(queryText) || + song.SongTags.Any(songTag => songTag.Tag.NormalizedName.Contains(queryText))); + } + + if (!string.IsNullOrWhiteSpace(tagKey)) + { + query = query.Where(song => song.SongTags.Any(songTag => songTag.Tag.NormalizedName == tagKey)); + } + + var songs = await query + .OrderBy(song => song.NormalizedTitle) + .ToListAsync(cancellationToken); + + return songs.Select(ToSongListItem).ToList(); + } + + public async Task> GetVisibleTagsAsync(CancellationToken cancellationToken = default) + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + return await db.Tags + .AsNoTracking() + .Where(tag => tag.SongTags.Any(songTag => !songTag.Song.IsHidden)) + .OrderBy(tag => tag.NormalizedName) + .Select(tag => tag.Name) + .ToListAsync(cancellationToken); + } + + public async Task AddViewerTagAsync(int songId, string rawTag, CancellationToken cancellationToken = default) + { + var displayTag = TextNormalizer.DisplayText(rawTag); + var tagKey = TextNormalizer.Key(rawTag); + + if (displayTag.Length is 0 or > 50) + { + throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。"); + } + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var songExists = await db.Songs.AnyAsync(song => song.Id == songId && !song.IsHidden, cancellationToken); + if (!songExists) + { + throw new InvalidOperationException("歌曲不存在或已隐藏。"); + } + + var tag = await db.Tags.FirstOrDefaultAsync(existing => existing.NormalizedName == tagKey, cancellationToken); + if (tag is null) + { + tag = new Tag { Name = displayTag, NormalizedName = tagKey, CreatedAt = DateTimeOffset.UtcNow }; + db.Tags.Add(tag); + } + + var alreadyLinked = await db.SongTags.AnyAsync( + songTag => songTag.SongId == songId && songTag.Tag.NormalizedName == tagKey, + cancellationToken); + + if (!alreadyLinked) + { + db.SongTags.Add(new SongTag + { + SongId = songId, + Tag = tag, + CreatedByViewer = true, + CreatedAt = DateTimeOffset.UtcNow + }); + } + + await db.SaveChangesAsync(cancellationToken); + } + + public async Task> GetAdminSongsAsync(CancellationToken cancellationToken = default) + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var songs = await db.Songs + .AsNoTracking() + .Include(song => song.SongTags) + .ThenInclude(songTag => songTag.Tag) + .OrderBy(song => song.NormalizedTitle) + .ToListAsync(cancellationToken); + + return songs + .Select(song => new AdminSongItem( + song.Id, + song.Title, + song.IsHidden, + song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList())) + .ToList(); + } + + public async Task CreateSongAsync(string rawTitle, CancellationToken cancellationToken = default) + { + var title = TextNormalizer.DisplayText(rawTitle); + var normalizedTitle = TextNormalizer.Key(title); + if (title.Length is 0 or > 200) + { + throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。"); + } + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var exists = await db.Songs.AnyAsync(song => song.NormalizedTitle == normalizedTitle, cancellationToken); + if (exists) + { + throw new InvalidOperationException("这个歌名已经存在。"); + } + + var now = DateTimeOffset.UtcNow; + db.Songs.Add(new Song + { + Title = title, + NormalizedTitle = normalizedTitle, + CreatedAt = now, + UpdatedAt = now + }); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateSongAsync(int songId, string rawTitle, bool isHidden, CancellationToken cancellationToken = default) + { + var title = TextNormalizer.DisplayText(rawTitle); + var normalizedTitle = TextNormalizer.Key(title); + if (title.Length is 0 or > 200) + { + throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。"); + } + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var song = await db.Songs.FindAsync([songId], cancellationToken) + ?? throw new InvalidOperationException("歌曲不存在。"); + + var duplicate = await db.Songs.AnyAsync( + existing => existing.Id != songId && existing.NormalizedTitle == normalizedTitle, + cancellationToken); + if (duplicate) + { + throw new InvalidOperationException("这个歌名已经存在。"); + } + + song.Title = title; + song.NormalizedTitle = normalizedTitle; + song.IsHidden = isHidden; + song.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteSongAsync(int songId, CancellationToken cancellationToken = default) + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var song = await db.Songs.FindAsync([songId], cancellationToken); + if (song is null) + { + return; + } + + db.Songs.Remove(song); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task> GetAdminTagsAsync(CancellationToken cancellationToken = default) + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + return await db.Tags + .AsNoTracking() + .OrderBy(tag => tag.NormalizedName) + .Select(tag => new AdminTagItem(tag.Id, tag.Name, tag.SongTags.Count)) + .ToListAsync(cancellationToken); + } + + public async Task RenameTagAsync(int tagId, string rawName, CancellationToken cancellationToken = default) + { + var displayName = TextNormalizer.DisplayText(rawName); + var tagKey = TextNormalizer.Key(rawName); + if (displayName.Length is 0 or > 50) + { + throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。"); + } + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var tag = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(existing => existing.Id == tagId, cancellationToken) + ?? throw new InvalidOperationException("标签不存在。"); + + var target = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync( + existing => existing.Id != tagId && existing.NormalizedName == tagKey, + cancellationToken); + + if (target is not null) + { + await MoveLinksAndDeleteTagAsync(db, tag, target, cancellationToken); + return; + } + + tag.Name = displayName; + tag.NormalizedName = tagKey; + await db.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteTagAsync(int tagId, CancellationToken cancellationToken = default) + { + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var tag = await db.Tags.FindAsync([tagId], cancellationToken); + if (tag is null) + { + return; + } + + db.Tags.Remove(tag); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task MergeTagAsync(int sourceTagId, string rawTargetName, CancellationToken cancellationToken = default) + { + var targetName = TextNormalizer.DisplayText(rawTargetName); + var targetKey = TextNormalizer.Key(rawTargetName); + if (targetName.Length is 0 or > 50) + { + throw new InvalidOperationException("目标标签长度需要在 1 到 50 个字符之间。"); + } + + await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); + var source = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.Id == sourceTagId, cancellationToken) + ?? throw new InvalidOperationException("来源标签不存在。"); + + var target = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.NormalizedName == targetKey, cancellationToken); + if (target is null) + { + target = new Tag { Name = targetName, NormalizedName = targetKey, CreatedAt = DateTimeOffset.UtcNow }; + db.Tags.Add(target); + await db.SaveChangesAsync(cancellationToken); + } + + if (source.Id == target.Id) + { + return; + } + + await MoveLinksAndDeleteTagAsync(db, source, target, cancellationToken); + } + + private static async Task MoveLinksAndDeleteTagAsync( + SongListDbContext db, + Tag source, + Tag target, + CancellationToken cancellationToken) + { + var links = await db.SongTags.Where(link => link.TagId == source.Id).ToListAsync(cancellationToken); + foreach (var link in links) + { + var targetLinkExists = await db.SongTags.AnyAsync( + existing => existing.SongId == link.SongId && existing.TagId == target.Id, + cancellationToken); + + if (!targetLinkExists) + { + db.SongTags.Add(new SongTag + { + SongId = link.SongId, + TagId = target.Id, + CreatedByViewer = link.CreatedByViewer, + CreatedAt = link.CreatedAt + }); + } + } + + db.Tags.Remove(source); + await db.SaveChangesAsync(cancellationToken); + } + + private static SongListItem ToSongListItem(Song song) + { + return new SongListItem( + song.Id, + song.Title, + song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList()); + } +} diff --git a/Services/SongDtos.cs b/Services/SongDtos.cs new file mode 100644 index 0000000..0278121 --- /dev/null +++ b/Services/SongDtos.cs @@ -0,0 +1,7 @@ +namespace lxc_songlist.Services; + +public sealed record SongListItem(int Id, string Title, IReadOnlyList Tags); + +public sealed record AdminSongItem(int Id, string Title, bool IsHidden, IReadOnlyList Tags); + +public sealed record AdminTagItem(int Id, string Name, int SongCount); diff --git a/Services/TextNormalizer.cs b/Services/TextNormalizer.cs new file mode 100644 index 0000000..96107da --- /dev/null +++ b/Services/TextNormalizer.cs @@ -0,0 +1,19 @@ +using System.Text.RegularExpressions; + +namespace lxc_songlist.Services; + +public static partial class TextNormalizer +{ + public static string DisplayText(string value) + { + return WhitespaceRegex().Replace(value.Trim(), " "); + } + + public static string Key(string value) + { + return DisplayText(value).ToLowerInvariant(); + } + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..44e6495 --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=127.0.0.1;Port=5432;Database=lxc_songlist;Username=lxc_songlist;Password=change-me" + }, + "Admin": { + "Password": "lxc-admin" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..d30900c --- /dev/null +++ b/appsettings.json @@ -0,0 +1,19 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=127.0.0.1;Port=5432;Database=lxc_songlist;Username=lxc_songlist;Password=change-me" + }, + "Admin": { + "PasswordHash": "", + "Password": "" + }, + "Site": { + "RequestTemplate": "点歌《{title}》" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/lxc-songlist.csproj b/lxc-songlist.csproj new file mode 100644 index 0000000..d69c600 --- /dev/null +++ b/lxc-songlist.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + lxc_songlist + $(AssemblyName.Replace(' ', '_')) + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/wwwroot/app.css b/wwwroot/app.css new file mode 100644 index 0000000..d5f6055 --- /dev/null +++ b/wwwroot/app.css @@ -0,0 +1,387 @@ +:root { + --porcelain-ink: #18344f; + --porcelain-muted: #55718c; + --porcelain-blue: #5b8ecf; + --porcelain-blue-deep: #356fa8; + --porcelain-line: rgba(104, 154, 199, 0.24); + --porcelain-surface: rgba(255, 255, 255, 0.84); + --porcelain-frost: rgba(235, 247, 255, 0.76); +} + +html, +body { + min-height: 100%; + color: var(--porcelain-ink); + background: + radial-gradient(circle at 18% 8%, rgba(183, 226, 245, 0.58), transparent 27rem), + radial-gradient(circle at 84% 16%, rgba(212, 233, 248, 0.78), transparent 24rem), + linear-gradient(145deg, #fafdff 0%, #edf7ff 48%, #f8fbff 100%); + font-family: Inter, "Noto Sans SC", "Microsoft YaHei", sans-serif; +} + +a { + color: var(--porcelain-blue-deep); +} + +h1:focus { + outline: none; +} + +.porcelain-main { + position: relative; + min-height: 100vh; + overflow: hidden; +} + +.porcelain-main::before { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + content: ""; + background-image: + linear-gradient(120deg, rgba(94, 142, 195, 0.07) 1px, transparent 1px), + linear-gradient(30deg, rgba(94, 142, 195, 0.05) 1px, transparent 1px); + background-size: 58px 58px, 74px 74px; + mask-image: linear-gradient(to bottom, black, transparent 92%); +} + +.content-shell { + position: relative; + z-index: 1; + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 88px 0 56px; +} + +.decor { + position: fixed; + z-index: 0; + pointer-events: none; + border: 1px solid rgba(91, 142, 207, 0.2); + background: rgba(255, 255, 255, 0.28); + transform: rotate(45deg); +} + +.decor-a { + top: 112px; + right: 9vw; + width: 98px; + height: 98px; + box-shadow: 0 0 34px rgba(148, 205, 232, 0.26); +} + +.decor-b { + bottom: 120px; + left: 5vw; + width: 62px; + height: 62px; +} + +.page-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) 220px; + gap: 24px; + align-items: end; + margin-bottom: 22px; +} + +.page-heading.compact { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.page-heading h1, +.page-heading .mud-typography-h1 { + margin-bottom: 6px; + font-size: 2.45rem; + line-height: 1.12; + letter-spacing: 0; +} + +.asset-slot { + min-height: 128px; + border: 1px solid var(--porcelain-line); + border-radius: 8px; + color: var(--porcelain-muted); + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(227, 244, 253, 0.72)), + repeating-linear-gradient(120deg, transparent 0 12px, rgba(91, 142, 207, 0.08) 12px 13px); +} + +.character-slot { + display: grid; + place-items: center; + gap: 4px; + text-align: center; + font-size: 0.9rem; +} + +.character-slot .mud-icon-root { + color: var(--porcelain-blue); + font-size: 2rem; +} + +.search-panel, +.admin-panel, +.admin-login-panel, +.song-card, +.empty-state { + border: 1px solid var(--porcelain-line); + border-radius: 8px; + background: var(--porcelain-surface); + box-shadow: 0 18px 50px rgba(61, 111, 154, 0.11); + backdrop-filter: blur(16px); +} + +.search-panel { + display: grid; + gap: 14px; + margin-bottom: 22px; + padding: 18px; +} + +.active-filter, +.tag-cloud, +.song-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.song-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.song-card { + display: grid; + gap: 16px; + padding: 18px; +} + +.song-card-main { + min-width: 0; +} + +.song-card-main h6, +.song-card-main .mud-typography-h6 { + overflow-wrap: anywhere; + letter-spacing: 0; +} + +.song-card-actions, +.viewer-tag-row, +.admin-add-row, +.admin-actions, +.panel-title-row { + display: flex; + gap: 10px; + align-items: center; +} + +.viewer-tag-row, +.admin-add-row { + align-items: stretch; +} + +.viewer-tag-row .mud-input-control, +.admin-add-row .mud-input-control { + flex: 1 1 auto; +} + +.loading-row, +.empty-state { + display: grid; + place-items: center; + gap: 12px; + min-height: 220px; + padding: 28px; + text-align: center; +} + +.admin-page, +.song-page { + display: grid; + gap: 18px; +} + +.admin-panel { + display: grid; + gap: 16px; + padding: 18px; +} + +.panel-title-row { + justify-content: space-between; +} + +.admin-list, +.tag-admin-list { + display: grid; + gap: 12px; +} + +.admin-list-item, +.tag-admin-item { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid rgba(104, 154, 199, 0.18); + border-radius: 8px; + background: rgba(248, 252, 255, 0.68); +} + +.admin-list-item.is-hidden { + opacity: 0.68; +} + +.admin-song-tags { + min-height: 32px; +} + +.admin-muted { + padding: 12px 0; +} + +.admin-login-page { + min-height: calc(100vh - 180px); + display: grid; + place-items: center; +} + +.admin-login-panel { + display: grid; + gap: 18px; + width: min(420px, 100%); + padding: 24px; +} + +.native-form { + display: grid; + gap: 14px; +} + +.native-field { + display: grid; + gap: 7px; + color: var(--porcelain-muted); + font-size: 0.92rem; +} + +.native-field input { + min-height: 48px; + width: 100%; + padding: 0 14px; + border: 1px solid rgba(85, 113, 140, 0.44); + border-radius: 8px; + color: var(--porcelain-ink); + background: rgba(255, 255, 255, 0.72); + font: inherit; +} + +.native-field input:focus { + outline: 2px solid rgba(91, 142, 207, 0.24); + border-color: var(--porcelain-blue); +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + padding: 1rem; + color: white; + background: #b32121; +} + +.blazor-error-boundary::after { + content: "An error has occurred."; +} + +#blazor-error-ui { + display: none; + position: fixed; + right: 16px; + bottom: 16px; + z-index: 10000; + max-width: min(420px, calc(100% - 32px)); + padding: 12px 16px; + border-radius: 8px; + color: white; + background: #8d2733; + box-shadow: 0 12px 32px rgba(30, 50, 70, 0.24); +} + +#blazor-error-ui .reload { + margin-left: 8px; + color: white; + font-weight: 700; +} + +#blazor-error-ui .dismiss { + margin-left: 12px; + cursor: pointer; +} + +@media (max-width: 900px) { + .content-shell { + width: min(100% - 24px, 720px); + padding-top: 76px; + } + + .page-heading, + .page-heading.compact { + grid-template-columns: 1fr; + gap: 14px; + } + + .character-slot { + min-height: 72px; + display: none; + } + + .song-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .content-shell { + width: min(100% - 18px, 480px); + padding-bottom: 34px; + } + + .page-heading h1, + .page-heading .mud-typography-h1 { + font-size: 1.85rem; + } + + .search-panel, + .admin-panel, + .song-card, + .admin-login-panel { + padding: 14px; + } + + .song-card-actions, + .viewer-tag-row, + .admin-add-row, + .admin-actions { + flex-wrap: wrap; + } + + .song-card-actions .mud-button-root, + .admin-actions .mud-button-root { + flex: 1 1 136px; + } +}