initial commit
This commit is contained in:
+482
@@ -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
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hans">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<title>洛星瓷歌单</title>
|
||||
<meta name="description" content="洛星瓷直播点歌歌单,支持歌名和标签搜索。" />
|
||||
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["lxc-songlist.styles.css"]" />
|
||||
<ImportMap />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<MudThemeProvider Theme="_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout Class="porcelain-app">
|
||||
<MudAppBar Class="porcelain-appbar" Elevation="0" Dense="true">
|
||||
<MudButton Href="/" Class="brand-button" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.MusicNote">
|
||||
洛星瓷歌单
|
||||
</MudButton>
|
||||
<MudSpacer />
|
||||
<MudHidden Breakpoint="Breakpoint.SmAndDown">
|
||||
<MudButton Href="/" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Search">
|
||||
搜歌
|
||||
</MudButton>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudButton Href="/admin" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.AdminPanelSettings">
|
||||
管理
|
||||
</MudButton>
|
||||
<MudButton Href="/admin/logout" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Logout">
|
||||
退出
|
||||
</MudButton>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<MudButton Href="/admin/login" Variant="Variant.Text" Color="Color.Inherit" StartIcon="@Icons.Material.Filled.Login">
|
||||
管理登录
|
||||
</MudButton>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</MudHidden>
|
||||
<MudHidden Breakpoint="Breakpoint.MdAndUp">
|
||||
<MudMenu Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Dense="true">
|
||||
<MudMenuItem Href="/" Icon="@Icons.Material.Filled.Search">搜歌</MudMenuItem>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudMenuItem Href="/admin" Icon="@Icons.Material.Filled.AdminPanelSettings">管理</MudMenuItem>
|
||||
<MudMenuItem Href="/admin/logout" Icon="@Icons.Material.Filled.Logout">退出</MudMenuItem>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<MudMenuItem Href="/admin/login" Icon="@Icons.Material.Filled.Login">管理登录</MudMenuItem>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</MudMenu>
|
||||
</MudHidden>
|
||||
</MudAppBar>
|
||||
<MudMainContent Class="porcelain-main">
|
||||
<div class="decor decor-a"></div>
|
||||
<div class="decor decor-b"></div>
|
||||
<div class="content-shell">
|
||||
@Body
|
||||
</div>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@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" }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
@page "/admin"
|
||||
@attribute [Authorize]
|
||||
@inject SongCatalogService Catalog
|
||||
@inject SiteSettingsService SiteSettings
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>歌单管理</PageTitle>
|
||||
|
||||
<section class="admin-page">
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<MudText Typo="Typo.h1">歌单管理</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">添加歌曲、隐藏曲目、清理标签和调整点歌文案。</MudText>
|
||||
</div>
|
||||
<MudButton Href="/admin/logout" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Logout">
|
||||
退出
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="admin-panel" Elevation="0">
|
||||
<div class="panel-title-row">
|
||||
<MudText Typo="Typo.h5">歌曲</MudText>
|
||||
<MudChip T="string" Color="Color.Info" Variant="Variant.Outlined">@_songs.Count 首</MudChip>
|
||||
</div>
|
||||
|
||||
<div class="admin-add-row">
|
||||
<MudTextField T="string"
|
||||
Value="_newSongTitle"
|
||||
ValueChanged="@((string value) => _newSongTitle = value)"
|
||||
Label="新增歌名"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Disabled="@string.IsNullOrWhiteSpace(_newSongTitle)"
|
||||
OnClick="CreateSong">
|
||||
添加
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_songs.Count == 0)
|
||||
{
|
||||
<MudText Class="admin-muted" Color="Color.Secondary">还没有歌曲。之后可以从这里手动添加,或直接导入数据库。</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="admin-list">
|
||||
@foreach (var song in _songs)
|
||||
{
|
||||
<MudPaper Class="@($"admin-list-item {(song.IsHidden ? "is-hidden" : string.Empty)}")" Elevation="0">
|
||||
<MudTextField T="string"
|
||||
Value="@song.Title"
|
||||
ValueChanged="@((string value) => song.Title = value)"
|
||||
Label="歌名"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
<MudCheckBox T="bool"
|
||||
Value="@song.IsHidden"
|
||||
ValueChanged="@((bool value) => song.IsHidden = value)"
|
||||
Label="隐藏" />
|
||||
<div class="song-tags admin-song-tags">
|
||||
@foreach (var tag in song.Tags)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info">@tag</MudChip>
|
||||
}
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="@(() => SaveSong(song))">
|
||||
保存
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" StartIcon="@Icons.Material.Filled.Delete" OnClick="@(() => DeleteSong(song))">
|
||||
删除
|
||||
</MudButton>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Class="admin-panel" Elevation="0">
|
||||
<MudText Typo="Typo.h5">站点设置</MudText>
|
||||
<MudTextField T="string"
|
||||
Value="_requestTemplate"
|
||||
ValueChanged="@((string value) => _requestTemplate = value)"
|
||||
Label="点歌文案模板"
|
||||
HelperText="使用 {title} 代表歌名"
|
||||
Variant="Variant.Outlined" />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveRequestTemplate">
|
||||
保存设置
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="admin-panel" Elevation="0">
|
||||
<div class="panel-title-row">
|
||||
<MudText Typo="Typo.h5">标签</MudText>
|
||||
<MudChip T="string" Color="Color.Info" Variant="Variant.Outlined">@_tags.Count 个</MudChip>
|
||||
</div>
|
||||
|
||||
@if (_tags.Count == 0)
|
||||
{
|
||||
<MudText Class="admin-muted" Color="Color.Secondary">暂无标签。</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tag-admin-list">
|
||||
@foreach (var tag in _tags)
|
||||
{
|
||||
<MudPaper Class="tag-admin-item" Elevation="0">
|
||||
<MudTextField T="string"
|
||||
Value="@tag.Name"
|
||||
ValueChanged="@((string value) => tag.Name = value)"
|
||||
Label="@($"标签,{tag.SongCount} 首")"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
<MudTextField T="string"
|
||||
Value="@tag.MergeTarget"
|
||||
ValueChanged="@((string value) => tag.MergeTarget = value)"
|
||||
Label="合并到"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
<div class="admin-actions">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Save" OnClick="@(() => RenameTag(tag))">
|
||||
改名
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.CallMerge" Disabled="@string.IsNullOrWhiteSpace(tag.MergeTarget)" OnClick="@(() => MergeTag(tag))">
|
||||
合并
|
||||
</MudButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Variant="Variant.Outlined" OnClick="@(() => DeleteTag(tag))" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private readonly List<AdminSongState> _songs = [];
|
||||
private readonly List<AdminTagState> _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<Task> 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<string> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@page "/admin/login"
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>管理登录</PageTitle>
|
||||
|
||||
<section class="admin-login-page">
|
||||
<MudPaper Class="admin-login-panel" Elevation="0">
|
||||
<MudText Typo="Typo.h4">管理登录</MudText>
|
||||
<MudText Color="Color.Secondary">输入管理员密码后可以维护歌单和标签。</MudText>
|
||||
|
||||
@if (Error == "1")
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">密码不正确。</MudAlert>
|
||||
}
|
||||
|
||||
<form method="post" action="/admin/sign-in" class="native-form">
|
||||
<input type="hidden" name="returnUrl" value="@ResolvedReturnUrl" />
|
||||
<label class="native-field">
|
||||
<span>管理员密码</span>
|
||||
<input type="password" name="password" required autocomplete="current-password" />
|
||||
</label>
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Login"
|
||||
FullWidth="true">
|
||||
登录
|
||||
</MudButton>
|
||||
</form>
|
||||
</MudPaper>
|
||||
</section>
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
@page "/"
|
||||
@inject SongCatalogService Catalog
|
||||
@inject SiteSettingsService SiteSettings
|
||||
@inject IJSRuntime Js
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>洛星瓷歌单</PageTitle>
|
||||
|
||||
<section class="song-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<MudText Typo="Typo.h1">洛星瓷歌单</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">快速查找会唱的歌,复制点歌文案,也可以给歌曲补充标签。</MudText>
|
||||
</div>
|
||||
<MudPaper Class="asset-slot character-slot" Elevation="0">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" />
|
||||
<span>角色图占位</span>
|
||||
</MudPaper>
|
||||
</div>
|
||||
|
||||
<MudPaper Class="search-panel" Elevation="0">
|
||||
<MudTextField T="string"
|
||||
Value="_search"
|
||||
ValueChanged="OnSearchChanged"
|
||||
Immediate="true"
|
||||
DebounceInterval="180"
|
||||
Placeholder="搜索歌名或标签"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Clearable="true" />
|
||||
|
||||
@if (_selectedTag is not null)
|
||||
{
|
||||
<div class="active-filter">
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Filled" OnClose="ClearTagFilter">
|
||||
标签:@_selectedTag
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_allTags.Count > 0)
|
||||
{
|
||||
<div class="tag-cloud" aria-label="标签筛选">
|
||||
@foreach (var tag in _allTags.Take(32))
|
||||
{
|
||||
<MudChip T="string"
|
||||
Color="@(_selectedTag == tag ? Color.Primary : Color.Info)"
|
||||
Variant="@(_selectedTag == tag ? Variant.Filled : Variant.Outlined)"
|
||||
OnClick="@(() => SelectTag(tag))">
|
||||
@tag
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="loading-row">
|
||||
<MudProgressCircular Indeterminate="true" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
else if (_songs.Count == 0)
|
||||
{
|
||||
<MudPaper Class="empty-state" Elevation="0">
|
||||
<MudIcon Icon="@Icons.Material.Filled.LibraryMusic" Size="Size.Large" />
|
||||
<MudText Typo="Typo.h6">歌单还是空的</MudText>
|
||||
<MudText Color="Color.Secondary">之后导入歌曲后,这里会显示可搜索的曲目。</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="song-grid">
|
||||
@foreach (var song in _songs)
|
||||
{
|
||||
<MudPaper Class="song-card" Elevation="0">
|
||||
<div class="song-card-main">
|
||||
<MudText Typo="Typo.h6">@song.Title</MudText>
|
||||
<div class="song-tags">
|
||||
@foreach (var tag in song.Tags)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info" OnClick="@(() => SelectTag(tag))">
|
||||
@tag
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="song-card-actions">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.ContentCopy"
|
||||
OnClick="@(() => CopyRequest(song.Title))">
|
||||
复制点歌
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<div class="viewer-tag-row">
|
||||
<MudTextField T="string"
|
||||
Value="@song.NewTag"
|
||||
ValueChanged="@((string value) => song.NewTag = value)"
|
||||
Placeholder="添加标签"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Add"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
Disabled="@string.IsNullOrWhiteSpace(song.NewTag)"
|
||||
OnClick="@(() => AddTag(song))" />
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private readonly List<SongCardState> _songs = [];
|
||||
private readonly List<string> _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<string> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<MudPaper Class="empty-state" Elevation="0">
|
||||
<MudIcon Icon="@Icons.Material.Filled.TravelExplore" Size="Size.Large" />
|
||||
<MudText Typo="Typo.h5">没有找到这个页面</MudText>
|
||||
<MudButton Href="/" Variant="Variant.Filled" Color="Color.Primary">回到歌单</MudButton>
|
||||
</MudPaper>
|
||||
@@ -0,0 +1,12 @@
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,51 @@
|
||||
using lxc_songlist.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace lxc_songlist.Data;
|
||||
|
||||
public sealed class SongListDbContext(DbContextOptions<SongListDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Song> Songs => Set<Song>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<SongTag> SongTags => Set<SongTag>();
|
||||
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Song>(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<Tag>(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<SongTag>(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<SiteSetting>(entity =>
|
||||
{
|
||||
entity.HasKey(setting => setting.Key);
|
||||
entity.Property(setting => setting.Key).HasMaxLength(80);
|
||||
entity.Property(setting => setting.Value).HasMaxLength(500);
|
||||
});
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("SiteSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("lxc_songlist.Models.Song", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("NormalizedTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("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<int>("SongId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TagId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("CreatedByViewer")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("SongId", "TagId");
|
||||
|
||||
b.HasIndex("TagId");
|
||||
|
||||
b.ToTable("SongTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("lxc_songlist.Models.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace lxc_songlist.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
Value = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
NormalizedTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
IsHidden = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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<int>(type: "integer", nullable: false),
|
||||
TagId = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedByViewer = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SongTags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Songs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tags");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// <auto-generated />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("character varying(80)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("SiteSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("lxc_songlist.Models.Song", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("NormalizedTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("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<int>("SongId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TagId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("CreatedByViewer")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("SongId", "TagId");
|
||||
|
||||
b.HasIndex("TagId");
|
||||
|
||||
b.ToTable("SongTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("lxc_songlist.Models.Tag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SongTag> SongTags { get; set; } = new List<SongTag>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SongTag> SongTags { get; set; } = new List<SongTag>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed class SiteOptions
|
||||
{
|
||||
public string RequestTemplate { get; set; } = "点歌《{title}》";
|
||||
}
|
||||
+105
@@ -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<AdminAuthOptions>(builder.Configuration.GetSection("Admin"));
|
||||
builder.Services.Configure<SiteOptions>(builder.Configuration.GetSection("Site"));
|
||||
|
||||
builder.Services.AddDbContextFactory<SongListDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
builder.Services.AddScoped<SongCatalogService>();
|
||||
builder.Services.AddScoped<SiteSettingsService>();
|
||||
builder.Services.AddSingleton<AdminPasswordService>();
|
||||
|
||||
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<App>()
|
||||
.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] != '\\'));
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed class AdminPasswordService(IOptions<AdminAuthOptions> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SongListDbContext> dbFactory,
|
||||
IOptions<SiteOptions> options)
|
||||
{
|
||||
public const string RequestTemplateKey = "request_template";
|
||||
|
||||
public async Task<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using lxc_songlist.Data;
|
||||
using lxc_songlist.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed class SongCatalogService(IDbContextFactory<SongListDbContext> dbFactory)
|
||||
{
|
||||
public async Task<IReadOnlyList<SongListItem>> 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<IReadOnlyList<string>> 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<IReadOnlyList<AdminSongItem>> 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<IReadOnlyList<AdminTagItem>> 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace lxc_songlist.Services;
|
||||
|
||||
public sealed record SongListItem(int Id, string Title, IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed record AdminSongItem(int Id, string Title, bool IsHidden, IReadOnlyList<string> Tags);
|
||||
|
||||
public sealed record AdminTagItem(int Id, string Name, int SongCount);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>lxc_songlist</RootNamespace>
|
||||
<AssemblyName>$(AssemblyName.Replace(' ', '_'))</AssemblyName>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="9.5.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+387
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user