Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b122f2481 | ||
|
|
f1e4b2a603 | ||
|
|
795225f097 |
No files matched your search
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Regression tests for the archive reconciliation pass: a multi-folder
|
||||||
|
// library must be reconciled against the UNION of its folders' walks, not
|
||||||
|
// once per folder. The old per-folder loop listed every library item
|
||||||
|
// against each single folder's walk, so with two registered folders every
|
||||||
|
// item living under the other folder was flagged "missing (1/2)" on every
|
||||||
|
// scan (prod: 743/743 audiobooks stuck at count 1 — unarchivable only
|
||||||
|
// because each scan's walk kept clearing them), and with three or more
|
||||||
|
// folders the crossed marks would have reached the archive threshold
|
||||||
|
// mid-pass and archived real content. Folder-book roots (directory
|
||||||
|
// file_path) must also count as present across folder boundaries — the
|
||||||
|
// file walk never emits a directory.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanTestFolders runs a synchronous scan over several folders — the
|
||||||
|
// multi-folder shape the archive pass must get right (scanTestFolder
|
||||||
|
// covers the single-folder case).
|
||||||
|
func scanTestFolders(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folders []string) {
|
||||||
|
t.Helper()
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
defer func() { _ = scanner.Close() }()
|
||||||
|
scanner.SetAdminID(adminID)
|
||||||
|
require.NoError(t, scanner.SetFolders(folders, false))
|
||||||
|
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchivePassMultiFolderLibraryKeepsEverythingPresent(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
libID, folderA := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Archive MultiFolder %d", time.Now().UnixNano()), "audiobooks")
|
||||||
|
t.Cleanup(func() { deleteLibraries(t, setup, libID) }) // after setup.Close defer (LIFO): pool still open
|
||||||
|
|
||||||
|
// Second registered folder in the SAME library — the shape the
|
||||||
|
// per-folder reconciliation got wrong.
|
||||||
|
folderB := t.TempDir()
|
||||||
|
_, err := setup.DB.AddLibraryFolder(context.Background(), database.AddLibraryFolderParams{
|
||||||
|
LibraryID: libID,
|
||||||
|
FolderPath: folderB,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
admin, _ := moveTestAdminID(t, setup)
|
||||||
|
|
||||||
|
// Folder A holds a lone single-file book; folder B holds a
|
||||||
|
// folder-of-tracks book whose file_path is a DIRECTORY.
|
||||||
|
writeTestTrack(t, filepath.Join(folderA, "Author A", "Lone Book", "lonely.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "01 First.mp3"))
|
||||||
|
writeTestTrack(t, filepath.Join(folderB, "Author B", "Folder Book", "02 Second.mp3"))
|
||||||
|
|
||||||
|
// Two consecutive scans: the first proves no flags are raised, the
|
||||||
|
// second proves the flag/walk bounce (clear → re-flag) is gone too.
|
||||||
|
for scanNo := 1; scanNo <= 2; scanNo++ {
|
||||||
|
scanTestFolders(t, setup, admin, []string{folderA, folderB})
|
||||||
|
|
||||||
|
items := listLibraryAudioItems(t, setup, libID)
|
||||||
|
var singleFile, folderBook bool
|
||||||
|
for _, item := range items {
|
||||||
|
assert.False(t, item.ArchivedAt.Valid, "scan %d: item %q must not be archived", scanNo, item.FilePath)
|
||||||
|
assert.Equal(t, int32(0), item.MissingScanCount, "scan %d: item %q must not be flagged missing", scanNo, item.FilePath)
|
||||||
|
switch item.FilePath {
|
||||||
|
case "Author A/Lone Book/lonely.mp3":
|
||||||
|
singleFile = true
|
||||||
|
case "Author B/Folder Book":
|
||||||
|
folderBook = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, singleFile, "scan %d: single-file book must exist", scanNo)
|
||||||
|
require.True(t, folderBook, "scan %d: folder book must exist at its directory path", scanNo)
|
||||||
|
require.Len(t, items, 2, "scan %d: exactly the two books, no per-track strays", scanNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -831,18 +831,47 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
||||||
// Every branch logs - the previous hard-delete cleanup failed silently
|
// Every branch logs - the previous hard-delete cleanup failed silently
|
||||||
// and left orphaned rows undetected.
|
// and left orphaned rows undetected.
|
||||||
|
// Group this scan's folders by owning library: the reconciliation is
|
||||||
|
// per LIBRARY, comparing every library item against the union of all
|
||||||
|
// its folders' walks. The old per-folder loop listed the whole
|
||||||
|
// library's items against each single folder's walk, so with two
|
||||||
|
// folders every item living under the other folder was flagged
|
||||||
|
// missing on every scan (prod: 743/743 audiobooks stuck at
|
||||||
|
// "missing (1/2)", unarchivable only because the walk kept clearing
|
||||||
|
// them) — and with three or more folders earlier iterations' marks
|
||||||
|
// crossed later ones' walks and archived real content mid-pass.
|
||||||
|
scannedSet := make(map[string]bool, len(s.folders))
|
||||||
|
for _, scanned := range s.folders {
|
||||||
|
scannedSet[scanned] = true
|
||||||
|
}
|
||||||
|
type archiveScope struct {
|
||||||
|
libraryID pgtype.UUID
|
||||||
|
name string
|
||||||
|
folders []string
|
||||||
|
}
|
||||||
|
scopes := make([]archiveScope, 0, len(s.folders))
|
||||||
|
scopeIndex := make(map[pgtype.UUID]int, len(s.folders))
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping it in archive pass: %v\n", folder, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, seen := scopeIndex[lib.LibraryID]
|
||||||
|
if !seen {
|
||||||
|
idx = len(scopes)
|
||||||
|
scopeIndex[lib.LibraryID] = idx
|
||||||
|
scopes = append(scopes, archiveScope{libraryID: lib.LibraryID, name: lib.Name})
|
||||||
|
}
|
||||||
|
scopes[idx].folders = append(scopes[idx].folders, folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scope := range scopes {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
libraryID := scope.libraryID
|
||||||
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
libraryID := lib.LibraryID
|
|
||||||
|
|
||||||
// Partial-scope guard (per-folder scans, 2026-10): the
|
// Partial-scope guard (per-folder scans, 2026-10): the
|
||||||
// reconciliation compares EVERY library item against this scan's
|
// reconciliation compares EVERY library item against this scan's
|
||||||
@@ -855,10 +884,6 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
|
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scannedSet := make(map[string]bool, len(s.folders))
|
|
||||||
for _, scanned := range s.folders {
|
|
||||||
scannedSet[scanned] = true
|
|
||||||
}
|
|
||||||
covered := 0
|
covered := 0
|
||||||
for _, rf := range registeredFolders {
|
for _, rf := range registeredFolders {
|
||||||
if scannedSet[rf.FolderPath] {
|
if scannedSet[rf.FolderPath] {
|
||||||
@@ -867,7 +892,7 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
if covered < len(registeredFolders) {
|
if covered < len(registeredFolders) {
|
||||||
fmt.Printf("[ARCHIVE] Skipping reconciliation for %s: scan covered %d of %d registered folders\n",
|
fmt.Printf("[ARCHIVE] Skipping reconciliation for %s: scan covered %d of %d registered folders\n",
|
||||||
lib.Name, covered, len(registeredFolders))
|
scope.name, covered, len(registeredFolders))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,8 +902,11 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build set of scanned file paths for this folder
|
// Build the set of scanned paths across ALL of this library's
|
||||||
|
// folders.
|
||||||
scannedPaths := make(map[string]bool)
|
scannedPaths := make(map[string]bool)
|
||||||
|
walkFailed := false
|
||||||
|
for _, folder := range scope.folders {
|
||||||
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
@@ -889,14 +917,24 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
||||||
continue // Skip to next folder to avoid false archivals
|
walkFailed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if walkFailed {
|
||||||
|
continue // Skip this library to avoid false archivals
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder-book roots count as present: the item's file_path is the
|
// Folder-book roots count as present: the item's file_path is the
|
||||||
// DIRECTORY, which the file walk above never emits.
|
// DIRECTORY, which the file walk above never emits. Attribution is
|
||||||
|
// boundary-safe — a sibling folder whose name merely extends this
|
||||||
|
// one's must not contribute its roots here.
|
||||||
for root := range s.audioBookRoots {
|
for root := range s.audioBookRoots {
|
||||||
if strings.HasPrefix(root, folder) {
|
for _, folder := range scope.folders {
|
||||||
|
if folderContains(folder, root) {
|
||||||
scannedPaths[s.getRelativePath(root)] = true
|
scannedPaths[s.getRelativePath(root)] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
|||||||
<link href="/static/style.css" rel="stylesheet"/>
|
<link href="/static/style.css" rel="stylesheet"/>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||||
</head>
|
</head>
|
||||||
<body class={ "theme-" + user.Theme }>
|
<body x-data="admin" class={ "theme-" + user.Theme }>
|
||||||
@Header(user, "/admin/archived")
|
@Header(user, "/admin/archived")
|
||||||
<main class="p-8">
|
<main class="p-8">
|
||||||
<div class="max-w-5xl">
|
<div class="max-w-5xl">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The archived-items admin page binds its buttons through the page-wide
|
||||||
|
// Alpine component: Purge All is @click="purgeArchivedItems()" and the
|
||||||
|
// per-row Delete/Restore/Merge buttons rely on listeners main.ts binds at
|
||||||
|
// module load. The body tag therefore MUST carry x-data="admin" — the
|
||||||
|
// other admin pages have it, and when this page's body lost it the purge
|
||||||
|
// button rendered as an inert attribute (Alpine only evaluates directives
|
||||||
|
// inside an x-data tree; the click did literally nothing).
|
||||||
|
//
|
||||||
|
// Regression: /admin/archived rendered <body class="theme-…"> with no
|
||||||
|
// Alpine scope, so "Purge All Archived" was dead (prod report 2026-10).
|
||||||
|
func TestAdminArchivedPageHasAlpineScope(t *testing.T) {
|
||||||
|
archivedAt := time.Date(2026, 10, 28, 4, 38, 0, 0, time.UTC)
|
||||||
|
var b strings.Builder
|
||||||
|
err := AdminArchived(User{ID: "u1", Timezone: "UTC"}, []ArchivedItem{{
|
||||||
|
ID: "m1",
|
||||||
|
Title: "Lost Book",
|
||||||
|
Author: "An Author",
|
||||||
|
LibraryName: "Audiobooks",
|
||||||
|
FilePath: "Author/Book/file.m4b",
|
||||||
|
MissingScans: 2,
|
||||||
|
ArchivedAt: &archivedAt,
|
||||||
|
Status: "Archived - pending purge",
|
||||||
|
}}, 0).Render(context.Background(), &b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
if !strings.Contains(out, `<body x-data="admin"`) {
|
||||||
|
t.Error("archived page body missing x-data=\"admin\" — the Alpine-bound purge button would be dead")
|
||||||
|
}
|
||||||
|
// The action buttons the page depends on must be present in the same
|
||||||
|
// render, so the pin cannot pass on an empty shell.
|
||||||
|
for _, want := range []string{"purgeArchivedItems()", `data-delete-archived="m1"`} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("archived page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"admin\" class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ templ BookShelf(
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
<div id="books-grid" class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4">
|
||||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ func BookShelf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
<div id="series-grid" class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||||
for _, series := range seriesList {
|
for _, series := range seriesList {
|
||||||
@SeriesCard(series)
|
@SeriesCard(series)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-7
@@ -1,8 +1,23 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
async function scanAllLibraries(): Promise<void> {
|
// Admin API calls authenticate with the Bearer token stashed at login,
|
||||||
|
// while the SSR pages themselves run on the cookie session — so a valid
|
||||||
|
// web session can still have an empty/stale localStorage token. User-
|
||||||
|
// triggered actions must never die silently in that state (the
|
||||||
|
// /admin/archived purge + delete buttons were invisible dead-ends: the
|
||||||
|
// missing-token return fired before the confirm dialog): requireToken
|
||||||
|
// fails loudly instead.
|
||||||
|
function requireToken(): string | null {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
showToast("Session expired — sign out and back in, then retry", "error");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAllLibraries(): Promise<void> {
|
||||||
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -225,7 +240,7 @@ function updateLibraryProgress(jobId: string, status: any): void {
|
|||||||
// aborts the walk at its next context check and skips the archive pass,
|
// aborts the walk at its next context check and skips the archive pass,
|
||||||
// so cancelling never archives unvisited files.
|
// so cancelling never archives unvisited files.
|
||||||
async function cancelScan(jobId: string): Promise<void> {
|
async function cancelScan(jobId: string): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
|
const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
|
||||||
@@ -257,7 +272,7 @@ async function cancelScan(jobId: string): Promise<void> {
|
|||||||
// list so it also covers jobs this page never started (e.g. started
|
// list so it also covers jobs this page never started (e.g. started
|
||||||
// before a refresh), then cancels each one.
|
// before a refresh), then cancels each one.
|
||||||
async function cancelAllScans(): Promise<void> {
|
async function cancelAllScans(): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -287,6 +302,8 @@ function setCancelAllVisible(visible: boolean): void {
|
|||||||
// widget and re-attach polling. Websocket start-events only reach pages
|
// widget and re-attach polling. Websocket start-events only reach pages
|
||||||
// that were already open when the scan began.
|
// that were already open when the scan began.
|
||||||
async function checkActiveScans(): Promise<void> {
|
async function checkActiveScans(): Promise<void> {
|
||||||
|
// Passive page-load probe, not a user action: stay silent when the API
|
||||||
|
// token is absent (requireToken's toast would fire on every page visit).
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@@ -369,6 +386,9 @@ function hideScanProgress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadWatchStatus(): Promise<void> {
|
async function loadWatchStatus(): Promise<void> {
|
||||||
|
// Passive status fetch inside the admin component's init, not a user
|
||||||
|
// action: stay silent when the API token is absent (requireToken's
|
||||||
|
// toast would fire on every page visit).
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
@@ -399,7 +419,7 @@ function stopScanStatusPolling(): void {
|
|||||||
// window for the library admin page's inline button; reading history is
|
// window for the library admin page's inline button; reading history is
|
||||||
// deleted with the rows, so a confirm dialog guards it.
|
// deleted with the rows, so a confirm dialog guards it.
|
||||||
async function purgeArchivedItems(): Promise<void> {
|
async function purgeArchivedItems(): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -431,7 +451,7 @@ async function purgeArchivedItems(): Promise<void> {
|
|||||||
// its file to return (POST /api/media-items/:id/unarchive). If the file is
|
// its file to return (POST /api/media-items/:id/unarchive). If the file is
|
||||||
// still gone the next scan hides it again.
|
// still gone the next scan hides it again.
|
||||||
async function unarchiveItem(id: string): Promise<void> {
|
async function unarchiveItem(id: string): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/media-items/${id}/unarchive`, {
|
const resp = await fetch(`/api/media-items/${id}/unarchive`, {
|
||||||
@@ -452,7 +472,7 @@ async function unarchiveItem(id: string): Promise<void> {
|
|||||||
// Permanently delete one archived item and its reading history
|
// Permanently delete one archived item and its reading history
|
||||||
// (DELETE /api/media-items/:id).
|
// (DELETE /api/media-items/:id).
|
||||||
async function deleteArchivedItem(id: string): Promise<void> {
|
async function deleteArchivedItem(id: string): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
@@ -481,7 +501,7 @@ async function deleteArchivedItem(id: string): Promise<void> {
|
|||||||
// same content hash (POST /api/media-items/:id/merge), then remove the
|
// same content hash (POST /api/media-items/:id/merge), then remove the
|
||||||
// archived entry. Covers copy-then-delete-later moves between libraries.
|
// archived entry. Covers copy-then-delete-later moves between libraries.
|
||||||
async function mergeArchivedItem(sourceId: string, targetId: string): Promise<void> {
|
async function mergeArchivedItem(sourceId: string, targetId: string): Promise<void> {
|
||||||
const token = localStorage.getItem("token");
|
const token = requireToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user