feat: add admin video deletion and mobile UI polish

Adds tombstone-backed video deletion with generated asset cleanup, plus responsive video management actions and centered confirmation dialogs.
This commit is contained in:
nianzhibai
2026-06-04 16:10:26 +08:00
parent 5080203b7c
commit 8dff0f07b9
24 changed files with 1403 additions and 167 deletions
+145
View File
@@ -202,6 +202,9 @@ func main() {
OnRegenFailedFingerprints: func(driveID string) {
go app.regenFailedFingerprints(ctx, driveID)
},
OnDeleteVideo: func(reqCtx context.Context, videoID string) (api.DeleteVideoResult, error) {
return app.deleteVideo(reqCtx, videoID)
},
GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses {
return app.driveGenerationStatuses()
},
@@ -1499,6 +1502,148 @@ func (a *App) cleanupMissingDriveVideos(ctx context.Context, driveID string, liv
return removed, nil
}
func (a *App) deleteVideo(ctx context.Context, videoID string) (api.DeleteVideoResult, error) {
if a == nil || a.cat == nil {
return api.DeleteVideoResult{}, sql.ErrNoRows
}
v, err := a.cat.GetVideo(ctx, videoID)
if err != nil {
return api.DeleteVideoResult{}, err
}
localDir := ""
if a.cfg != nil {
localDir = a.cfg.Storage.LocalPreviewDir
}
if err := removeLocalVideoAssets(localDir, v); err != nil {
return api.DeleteVideoResult{}, fmt.Errorf("remove local assets for %s: %w", v.ID, err)
}
deletedSource, err := a.removeSpider91SourceFile(ctx, v)
if err != nil {
return api.DeleteVideoResult{}, err
}
if err := a.cat.DeleteVideoWithTombstone(ctx, v.ID); err != nil {
return api.DeleteVideoResult{}, err
}
return api.DeleteVideoResult{OK: true, DeletedSource: deletedSource}, nil
}
func (a *App) removeSpider91SourceFile(ctx context.Context, v *catalog.Video) (bool, error) {
if a == nil || a.cfg == nil || v == nil || !strings.HasPrefix(v.ID, "spider91-") {
return false, nil
}
driveID, sourceID := a.spider91OriginFromVideo(ctx, v)
if driveID == "" || sourceID == "" {
return false, nil
}
src := spider91.New(spider91.Config{
ID: driveID,
RootDir: a.spider91DriveDir(driveID),
})
deleted := false
for _, fileID := range spider91SourceFileCandidates(v, driveID, sourceID) {
videoPath, err := src.VideoPath(fileID)
if err != nil {
continue
}
info, err := os.Stat(videoPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
return deleted, fmt.Errorf("stat spider91 source %s: %w", videoPath, err)
}
if info.IsDir() {
continue
}
if err := os.Remove(videoPath); err != nil && !os.IsNotExist(err) {
return deleted, fmt.Errorf("remove spider91 source %s: %w", videoPath, err)
}
deleted = true
removeSpider91ThumbCandidates(src, strings.TrimSuffix(fileID, filepath.Ext(fileID)))
}
if !deleted {
removeSpider91ThumbCandidates(src, sourceID)
}
return deleted, nil
}
func (a *App) spider91OriginFromVideo(ctx context.Context, v *catalog.Video) (string, string) {
if a == nil || v == nil {
return "", ""
}
if d, err := a.cat.GetDrive(ctx, v.DriveID); err == nil && d != nil && d.Kind == spider91.Kind {
prefix := "spider91-" + d.ID + "-"
if strings.HasPrefix(v.ID, prefix) {
return d.ID, strings.TrimPrefix(v.ID, prefix)
}
}
drives, err := a.cat.ListDrives(ctx)
if err != nil {
return "", ""
}
bestDriveID := ""
bestSourceID := ""
for _, d := range drives {
if d == nil || d.Kind != spider91.Kind {
continue
}
prefix := "spider91-" + d.ID + "-"
if !strings.HasPrefix(v.ID, prefix) {
continue
}
if len(d.ID) > len(bestDriveID) {
bestDriveID = d.ID
bestSourceID = strings.TrimPrefix(v.ID, prefix)
}
}
return bestDriveID, bestSourceID
}
func spider91SourceFileCandidates(v *catalog.Video, originDriveID, sourceID string) []string {
candidates := []string{}
if v != nil && v.DriveID == originDriveID && strings.TrimSpace(v.FileID) != "" {
candidates = append(candidates, strings.TrimSpace(v.FileID))
}
if ext := strings.Trim(strings.TrimSpace(v.Ext), "."); ext != "" {
candidates = append(candidates, sourceID+"."+ext)
}
for _, ext := range []string{".mp4", ".mkv", ".mov", ".webm", ".avi"} {
candidates = append(candidates, sourceID+ext)
}
seen := make(map[string]struct{}, len(candidates))
out := make([]string, 0, len(candidates))
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
out = append(out, candidate)
}
return out
}
func removeSpider91ThumbCandidates(src *spider91.Driver, stem string) {
if src == nil {
return
}
stem = strings.TrimSpace(stem)
if stem == "" {
return
}
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
thumbPath, err := src.ThumbPath(stem + ext)
if err != nil {
continue
}
_ = os.Remove(thumbPath)
}
}
func (a *App) cleanupDriveVideosForDelete(ctx context.Context, driveID string) (int, error) {
if a == nil || a.cat == nil {
return 0, nil
+171
View File
@@ -943,6 +943,177 @@ func TestCleanupDriveVideosForDeleteRemovesRowsAndGeneratedAssetsOnly(t *testing
}
}
func TestDeleteVideoRemovesGeneratedAssetsKeepsLocalOriginalAndTombstones(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
localDir := filepath.Join(root, "previews")
originalDir := filepath.Join(root, "local-videos")
originalVideo := filepath.Join(originalDir, "clip.mp4")
if err := os.MkdirAll(originalDir, 0o755); err != nil {
t.Fatalf("mkdir original dir: %v", err)
}
if err := os.WriteFile(originalVideo, []byte("original"), 0o644); err != nil {
t.Fatalf("write original: %v", err)
}
cat, err := catalog.Open(filepath.Join(t.TempDir(), "catalog.db"))
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
if err := cat.UpsertDrive(ctx, &catalog.Drive{
ID: "local-main",
Kind: "localstorage",
Name: "Local",
RootID: "/",
Credentials: map[string]string{"path": originalDir},
TeaserEnabled: true,
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
previewPath := filepath.Join(localDir, "localstorage-local-main-file.mp4")
thumbPath := filepath.Join(localDir, "thumbs", "localstorage-local-main-file.jpg")
for _, path := range []string{previewPath, thumbPath} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "localstorage-local-main-file",
DriveID: "local-main",
FileID: "file",
FileName: "clip.mp4",
SampledSHA256: "sampled",
FingerprintStatus: "ready",
Title: "Local File",
PreviewLocal: previewPath,
PreviewStatus: "ready",
ThumbnailURL: "/p/thumb/localstorage-local-main-file",
Size: 123,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
app := &App{
cfg: &config.Config{Storage: config.Storage{LocalPreviewDir: localDir}},
cat: cat,
}
result, err := app.deleteVideo(ctx, "localstorage-local-main-file")
if err != nil {
t.Fatalf("delete video: %v", err)
}
if !result.OK || result.DeletedSource {
t.Fatalf("delete result = %#v, want ok without source deletion", result)
}
if _, err := cat.GetVideo(ctx, "localstorage-local-main-file"); err != sql.ErrNoRows {
t.Fatalf("deleted video lookup error = %v, want sql.ErrNoRows", err)
}
deleted, err := cat.IsDeletedVideoCandidate(ctx, "localstorage-local-main-file", "local-main", "file", "", "clip.mp4", 123)
if err != nil {
t.Fatalf("check tombstone: %v", err)
}
if !deleted {
t.Fatal("deleted video tombstone missing")
}
for _, path := range []string{previewPath, thumbPath} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("generated asset %s still exists, stat err=%v", path, err)
}
}
if _, err := os.Stat(originalVideo); err != nil {
t.Fatalf("original local video was removed: %v", err)
}
}
func TestDeleteVideoRemovesSpider91SourceFile(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
localDir := filepath.Join(root, "previews")
cat, err := catalog.Open(filepath.Join(t.TempDir(), "catalog.db"))
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
if err := cat.UpsertDrive(ctx, &catalog.Drive{
ID: "spider-main",
Kind: spider91.Kind,
Name: "Spider",
RootID: "/",
TeaserEnabled: true,
}); err != nil {
t.Fatalf("seed drive: %v", err)
}
app := &App{
cfg: &config.Config{Storage: config.Storage{LocalPreviewDir: localDir}},
cat: cat,
}
sourceDir := app.spider91DriveDir("spider-main")
sourceVideo := filepath.Join(sourceDir, "videos", "source.mp4")
sourceThumb := filepath.Join(sourceDir, "thumbs", "source.jpg")
previewPath := filepath.Join(localDir, "spider91-spider-main-source.mp4")
commonThumb := filepath.Join(localDir, "thumbs", "spider91-spider-main-source.jpg")
for _, path := range []string{sourceVideo, sourceThumb, previewPath, commonThumb} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte("file"), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "spider91-spider-main-source",
DriveID: "spider-main",
FileID: "source.mp4",
FileName: "source.mp4",
Ext: "mp4",
Title: "Spider Source",
PreviewLocal: previewPath,
PreviewStatus: "ready",
ThumbnailURL: "/p/thumb/spider91-spider-main-source",
Size: 456,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
result, err := app.deleteVideo(ctx, "spider91-spider-main-source")
if err != nil {
t.Fatalf("delete spider video: %v", err)
}
if !result.OK || !result.DeletedSource {
t.Fatalf("delete result = %#v, want source deleted", result)
}
for _, path := range []string{sourceVideo, sourceThumb, previewPath, commonThumb} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("deleted file %s still exists, stat err=%v", path, err)
}
}
if _, err := cat.GetVideo(ctx, "spider91-spider-main-source"); err != sql.ErrNoRows {
t.Fatalf("deleted video lookup error = %v, want sql.ErrNoRows", err)
}
deleted, err := cat.IsVideoDeleted(ctx, "spider91-spider-main-source")
if err != nil {
t.Fatalf("check tombstone: %v", err)
}
if !deleted {
t.Fatal("deleted spider91 video tombstone missing")
}
}
func TestCleanupDriveVideosForDeleteSpider91RemovesCrawledDirAndOriginRecords(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
+37
View File
@@ -53,6 +53,7 @@ type AdminServer struct {
OnRegenFailedPreviews func(driveID string)
OnRegenFailedThumbnails func(driveID string)
OnRegenFailedFingerprints func(driveID string)
OnDeleteVideo func(ctx context.Context, videoID string) (DeleteVideoResult, error)
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
@@ -107,6 +108,11 @@ type NightlyJobStatus struct {
LastFinishedAt string `json:"lastFinishedAt,omitempty"`
}
type DeleteVideoResult struct {
OK bool `json:"ok"`
DeletedSource bool `json:"deletedSource"`
}
func (a *AdminServer) Register(r chi.Router) {
r.Route("/admin/api", func(r chi.Router) {
// 登录、登出和首次部署初始化不需要鉴权
@@ -139,6 +145,7 @@ func (a *AdminServer) Register(r chi.Router) {
// 视频
r.Get("/videos", a.handleAdminListVideos)
r.Put("/videos/{id}", a.handleUpdateVideo)
r.Delete("/videos/{id}", a.handleDeleteVideo)
r.Post("/videos/regen-preview", a.handleRegenAllPreviews)
r.Post("/videos/{id}/regen-preview", a.handleRegenPreview)
@@ -1031,6 +1038,36 @@ func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, v)
}
func (a *AdminServer) handleDeleteVideo(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeErr(w, http.StatusBadRequest, errors.New("invalid video id"))
return
}
var (
result DeleteVideoResult
err error
)
if a.OnDeleteVideo != nil {
result, err = a.OnDeleteVideo(r.Context(), id)
} else {
err = a.Catalog.DeleteVideoWithTombstone(r.Context(), id)
result = DeleteVideoResult{OK: err == nil}
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, err)
return
}
writeErr(w, http.StatusInternalServerError, err)
return
}
if !result.OK {
result.OK = true
}
writeJSON(w, http.StatusOK, result)
}
func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if a.OnRegenPreview != nil {
+117 -2
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -707,8 +708,10 @@ func (c *Catalog) ListVideoFileIDsByDrive(ctx context.Context, driveID string) (
func (c *Catalog) ListSpider91Viewkeys(ctx context.Context, driveID string) ([]string, error) {
prefix := "spider91-" + driveID + "-"
rows, err := c.db.QueryContext(ctx,
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'`,
len(prefix)+1, prefix)
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'
UNION
SELECT SUBSTR(id, ?) FROM deleted_videos WHERE id LIKE ? || '%'`,
len(prefix)+1, prefix, len(prefix)+1, prefix)
if err != nil {
return nil, err
}
@@ -726,6 +729,69 @@ func (c *Catalog) ListSpider91Viewkeys(ctx context.Context, driveID string) ([]s
return out, rows.Err()
}
// DeleteVideoWithTombstone records that an administrator explicitly deleted a
// video, then removes the visible catalog row. The tombstone is used by
// scanners/crawlers to avoid importing the same source file again.
func (c *Catalog) DeleteVideoWithTombstone(ctx context.Context, id string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var v struct {
ID string
DriveID string
FileID string
ContentHash string
FileName string
Size int64
}
row := tx.QueryRowContext(ctx, `
SELECT id, drive_id, file_id, COALESCE(content_hash, ''), COALESCE(file_name, ''), size_bytes
FROM videos
WHERE id = ?`, id)
if err := row.Scan(&v.ID, &v.DriveID, &v.FileID, &v.ContentHash, &v.FileName, &v.Size); err != nil {
return err
}
v.ContentHash = normalizeContentHash(v.ContentHash)
// 先记录这次视频关联的 tag_id,便于事务末尾清理孤儿 collection 标签。
tagIDs, err := collectVideoTagIDs(ctx, tx, id)
if err != nil {
return err
}
now := time.Now().UnixMilli()
if _, err := tx.ExecContext(ctx, `
INSERT INTO deleted_videos (id, drive_id, file_id, content_hash, file_name, size_bytes, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
drive_id = excluded.drive_id,
file_id = excluded.file_id,
content_hash = excluded.content_hash,
file_name = excluded.file_name,
size_bytes = excluded.size_bytes,
deleted_at = excluded.deleted_at`,
v.ID, v.DriveID, v.FileID, v.ContentHash, v.FileName, v.Size, now); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE video_id = ?`, id); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM videos WHERE id = ?`, id)
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return sql.ErrNoRows
}
if err := pruneOrphanCollectionTagsByID(ctx, tx, tagIDs); err != nil {
return err
}
return tx.Commit()
}
func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
@@ -759,6 +825,55 @@ func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
return tx.Commit()
}
func (c *Catalog) IsVideoDeleted(ctx context.Context, id string) (bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return false, nil
}
var found int
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM deleted_videos WHERE id = ? LIMIT 1`, id).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (c *Catalog) IsDeletedVideoCandidate(ctx context.Context, id, driveID, fileID, contentHash, fileName string, size int64) (bool, error) {
id = strings.TrimSpace(id)
driveID = strings.TrimSpace(driveID)
fileID = strings.TrimSpace(fileID)
contentHash = normalizeContentHash(contentHash)
fileName = strings.TrimSpace(fileName)
if id == "" && driveID == "" {
return false, nil
}
var found int
err := c.db.QueryRowContext(ctx, `
SELECT 1
FROM deleted_videos
WHERE id = ?
OR (drive_id = ? AND ? != '' AND file_id = ?)
OR (drive_id = ? AND ? != '' AND content_hash = ?)
OR (drive_id = ? AND ? != '' AND ? > 0 AND file_name = ? AND size_bytes = ?)
LIMIT 1`,
id,
driveID, fileID, fileID,
driveID, contentHash, contentHash,
driveID, fileName, size, fileName, size,
).Scan(&found)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (c *Catalog) FindVideoByContentHash(ctx context.Context, hash string) (*Video, error) {
hash = normalizeContentHash(hash)
if hash == "" {
+48
View File
@@ -2,6 +2,7 @@ package catalog
import (
"context"
"database/sql"
"sort"
"testing"
"time"
@@ -126,3 +127,50 @@ func TestListSpider91ViewkeysFindsMigratedVideos(t *testing.T) {
t.Fatalf("non-existent drive: got %v, want empty", other)
}
}
func TestDeleteVideoWithTombstonePreventsReimport(t *testing.T) {
ctx := context.Background()
cat, err := Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() { _ = cat.Close() })
now := time.Now()
if err := cat.UpsertVideo(ctx, &Video{
ID: "spider91-91Spider-vk004",
DriveID: "91Spider",
FileID: "vk004.mp4",
FileName: "vk004.mp4",
ContentHash: "ABCDEF",
Title: "Deleted Spider",
Size: 2048,
PreviewStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := cat.DeleteVideoWithTombstone(ctx, "spider91-91Spider-vk004"); err != nil {
t.Fatalf("delete with tombstone: %v", err)
}
if _, err := cat.GetVideo(ctx, "spider91-91Spider-vk004"); err != sql.ErrNoRows {
t.Fatalf("get deleted video error = %v, want sql.ErrNoRows", err)
}
deleted, err := cat.IsDeletedVideoCandidate(ctx, "spider91-91Spider-vk004", "91Spider", "vk004.mp4", "abcdef", "vk004.mp4", 2048)
if err != nil {
t.Fatalf("check deleted candidate: %v", err)
}
if !deleted {
t.Fatal("deleted candidate was not recognized")
}
viewkeys, err := cat.ListSpider91Viewkeys(ctx, "91Spider")
if err != nil {
t.Fatalf("ListSpider91Viewkeys: %v", err)
}
if len(viewkeys) != 1 || viewkeys[0] != "vk004" {
t.Fatalf("viewkeys = %#v, want [vk004]", viewkeys)
}
}
+19
View File
@@ -70,6 +70,25 @@ CREATE TABLE IF NOT EXISTS deleted_tags (
deleted_at INTEGER NOT NULL
);
-- 管理员显式删除过的视频。用于防止后续扫描 / spider91 爬虫把同一个源文件
-- 再次入库;不代表原始云盘文件已被删除。
CREATE TABLE IF NOT EXISTS deleted_videos (
id TEXT PRIMARY KEY,
drive_id TEXT NOT NULL DEFAULT '',
file_id TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_file
ON deleted_videos(drive_id, file_id);
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_hash
ON deleted_videos(drive_id, content_hash);
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_signature
ON deleted_videos(drive_id, file_name, size_bytes);
-- 网盘账户
CREATE TABLE IF NOT EXISTS drives (
id TEXT PRIMARY KEY,
+21
View File
@@ -79,6 +79,18 @@ func (c *Catalog) migrate(ctx context.Context) error {
if err := c.addColumnIfMissing(ctx, "drives", "skip_dir_ids", "TEXT NOT NULL DEFAULT '[]'"); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS deleted_videos (
id TEXT PRIMARY KEY,
drive_id TEXT NOT NULL DEFAULT '',
file_id TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '',
file_name TEXT NOT NULL DEFAULT '',
size_bytes INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER NOT NULL
)`); err != nil {
return err
}
if err := c.syncDriveScanRootIDToRootID(ctx); err != nil {
return err
}
@@ -121,6 +133,15 @@ func (c *Catalog) migrate(ctx context.Context) error {
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_file_name_size_created ON videos(file_name, size_bytes, created_at, id)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_file ON deleted_videos(drive_id, file_id)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_hash ON deleted_videos(drive_id, content_hash)`); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_signature ON deleted_videos(drive_id, file_name, size_bytes)`); err != nil {
return err
}
if err := c.seedSystemTags(ctx); err != nil {
return err
}
@@ -331,6 +331,16 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
break
}
videoID := buildVideoID(c.cfg.Driver.ID(), sourceID)
deleted, err := c.cfg.Catalog.IsVideoDeleted(ctx, videoID)
if err != nil {
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s check deleted: %v", c.cfg.Driver.ID(), item.Viewkey, sourceID, err)
result.Failed++
continue
}
if deleted {
result.Skipped++
continue
}
if existing, _ := c.cfg.Catalog.GetVideo(ctx, videoID); existing != nil {
result.Skipped++
continue
+37 -17
View File
@@ -1362,12 +1362,19 @@ func (w *ThumbWorker) Run(ctx context.Context) {
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
defer w.queue.release(v)
w.activity.start(v)
if w.Catalog == nil || v == nil || v.ID == "" {
return
}
current, err := w.Catalog.GetVideo(ctx, v.ID)
if err != nil || current.Hidden {
return
}
w.activity.start(current)
defer w.activity.done()
if !waitForRateLimitCooldown(ctx, &w.rateLimit, "preview", w.Drive) {
return
}
w.process(ctx, v)
w.process(ctx, current)
}
func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) {
@@ -1562,18 +1569,22 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
if w.skipIfRateLimited(v) {
return false
}
if w.Catalog == nil || v == nil || v.ID == "" {
return false
}
queued := v
current := v
if loaded, err := w.Catalog.GetVideo(ctx, v.ID); err == nil {
if loaded.PreviewLocal == "" {
loaded.PreviewLocal = queued.PreviewLocal
}
current = loaded
v = loaded
if loaded.ThumbnailURL != "" && loaded.DurationSeconds > 0 {
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
return false
}
loaded, err := w.Catalog.GetVideo(ctx, v.ID)
if err != nil || loaded.Hidden {
return false
}
if loaded.PreviewLocal == "" {
loaded.PreviewLocal = queued.PreviewLocal
}
current := loaded
v = loaded
if loaded.ThumbnailURL != "" && loaded.DurationSeconds > 0 {
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
return false
}
if current.ThumbnailURL != "" {
durationBackfillFailed := false
@@ -1670,13 +1681,18 @@ func (w *ThumbWorker) probeDuration(ctx context.Context, v *catalog.Video, link
}
func (w *ThumbWorker) generateThumbnailFromLink(ctx context.Context, v *catalog.Video, link *drives.StreamLink) error {
if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, float64(v.DurationSeconds)); err != nil {
local, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, float64(v.DurationSeconds))
if err != nil {
return err
}
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
if err := w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
ThumbnailURL: "/p/thumb/" + v.ID,
ThumbnailStatus: "ready",
})
}); err != nil {
_ = os.Remove(local)
log.Printf("[thumb] update %s after generate: %v", v.Title, err)
return nil
}
log.Printf("[thumb] ready %s", v.Title)
return nil
}
@@ -1751,7 +1767,11 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
}
removePreviousLocalTeaser(v.PreviewLocal, local)
w.Catalog.UpdatePreview(ctx, v.ID, local, "ready")
if err := w.Catalog.UpdatePreview(ctx, v.ID, local, "ready"); err != nil {
removePreviousLocalTeaser(local, "")
log.Printf("[preview] update %s after generate: %v", v.Title, err)
return
}
log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration)
}
+8
View File
@@ -154,6 +154,14 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
stats.SeenFileIDs[e.ID] = struct{}{}
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
if deleted, err := s.Catalog.IsDeletedVideoCandidate(ctx, id, s.Drive.ID(), e.ID, e.Hash, e.Name, e.Size); err != nil {
stats.Errors++
log.Printf("[scanner] check deleted video %s error: %v", id, err)
continue
} else if deleted {
continue
}
parsed := Parse(e.Name)
if parsed.Title == "" {
parsed.Title = strings.TrimSuffix(e.Name, ext)
+55
View File
@@ -90,6 +90,61 @@ func TestRunIgnoresZeroSizeVideoFiles(t *testing.T) {
}
}
func TestRunSkipsAdminDeletedVideo(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
t.Fatalf("open catalog: %v", err)
}
t.Cleanup(func() {
if err := cat.Close(); err != nil {
t.Fatalf("close catalog: %v", err)
}
})
now := time.Now()
if err := cat.UpsertVideo(ctx, &catalog.Video{
ID: "fake-drive-file-1",
DriveID: "drive",
FileID: "file-1",
FileName: "clip.mp4",
ContentHash: "HASH-1",
Title: "Deleted Clip",
Size: 123,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("seed video: %v", err)
}
if err := cat.DeleteVideoWithTombstone(ctx, "fake-drive-file-1"); err != nil {
t.Fatalf("delete with tombstone: %v", err)
}
drv := &scannerFakeDrive{
entries: []drives.Entry{{
ID: "file-1",
Name: "clip.mp4",
Size: 123,
Hash: "hash-1",
MimeType: "video/mp4",
ModTime: now,
}},
}
sc := New(cat, drv, []string{".mp4"}, nil, nil)
stats, err := sc.Run(ctx, "")
if err != nil {
t.Fatalf("scan: %v", err)
}
if stats.Added != 0 {
t.Fatalf("added = %d, want 0", stats.Added)
}
if _, err := cat.GetVideo(ctx, "fake-drive-file-1"); err != sql.ErrNoRows {
t.Fatalf("deleted video was recreated, get error = %v", err)
}
}
func TestRunDoesNotBackfillRemoteThumbnailForExistingVideo(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+7 -2
View File
@@ -9,6 +9,8 @@ type ConfirmModalProps = {
confirmText?: string;
cancelText?: string;
danger?: boolean;
centerMessage?: boolean;
modalClassName?: string;
loading?: boolean;
onCancel: () => void;
onConfirm: () => void;
@@ -22,6 +24,8 @@ export function ConfirmModal({
confirmText = "确认",
cancelText = "取消",
danger = false,
centerMessage = false,
modalClassName = "",
loading = false,
onCancel,
onConfirm,
@@ -31,6 +35,7 @@ export function ConfirmModal({
open={open}
title={title}
onClose={onCancel}
className={modalClassName}
footer={
<>
<button type="button" className="admin-btn" onClick={onCancel} disabled={loading}>
@@ -47,8 +52,8 @@ export function ConfirmModal({
</>
}
>
<div className="admin-confirm">
<div className={`admin-confirm__icon${danger ? " is-danger" : ""}`}>
<div className={`admin-confirm${centerMessage ? " is-message-centered" : ""}`}>
<div className={`admin-confirm__icon${danger ? " is-danger" : ""}`} aria-hidden={centerMessage}>
<AlertTriangle size={20} />
</div>
<div className="admin-confirm__content">
+4
View File
@@ -650,6 +650,8 @@ export function DrivesPage() {
message="当前网盘配置有未保存的更改,确定要放弃吗?"
confirmText="放弃更改"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
onCancel={() => setDiscardConfirmOpen(false)}
onConfirm={discardDriveChanges}
/>
@@ -787,6 +789,8 @@ export function DrivesPage() {
message="当前网盘配置有未保存的更改,确定要放弃吗?"
confirmText="放弃更改"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
onCancel={() => setDiscardConfirmOpen(false)}
onConfirm={discardDriveChanges}
/>
+3 -2
View File
@@ -7,9 +7,10 @@ type Props = {
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
className?: string;
};
export function Modal({ open, title, onClose, children, footer }: Props) {
export function Modal({ open, title, onClose, children, footer, className = "" }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
@@ -76,7 +77,7 @@ export function Modal({ open, title, onClose, children, footer }: Props) {
>
<div
ref={dialogRef}
className="admin-modal"
className={`admin-modal${className ? ` ${className}` : ""}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
+4 -6
View File
@@ -239,9 +239,6 @@ export function TagsPage() {
onChange={(e) => setAliases(e.target.value)}
placeholder="逗号分隔,例如:纯欲, 清新"
/>
<div className="admin-form__help">
</div>
</div>
<button
type="submit"
@@ -495,12 +492,13 @@ export function TagsPage() {
title={deleteConfirm?.kind === "bulk" ? "删除选中标签" : "删除标签"}
message={
deleteConfirm?.kind === "bulk"
? `确定删除选中的 ${deleteConfirm.ids.length} 个标签吗?`
: `确定删除标签「${deleteConfirm?.tag.label ?? ""}」吗?`
? `确定删除选中的 ${deleteConfirm.ids.length} 个标签吗?`
: `确定删除标签「${deleteConfirm?.tag.label ?? ""}」吗?`
}
details={["此操作会从所有视频上移除相关标签。"]}
confirmText="确认删除"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
loading={deletingId !== null || bulkDeleting}
onCancel={() => {
if (deletingId === null && !bulkDeleting) setDeleteConfirm(null);
-3
View File
@@ -103,9 +103,6 @@ export function ThemePage() {
<div className="theme-page">
<header className="theme-page__head">
<h1 className="theme-page__title"></h1>
<p className="theme-page__sub">
访
</p>
</header>
<div className="theme-grid">
+185 -59
View File
@@ -1,12 +1,14 @@
import { useEffect, useId, useState } from "react";
import { Edit, RefreshCw, Search, CheckSquare, Square, Image } from "lucide-react";
import { ChevronDown, Edit, RefreshCw, Search, CheckSquare, Square, Image, Trash2 } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
import { ConfirmModal } from "./ConfirmModal";
import { formatBytes } from "./storageFormat";
const PAGE_SIZE = 100;
const DESKTOP_VIDEOS_PAGE_SIZE = 50;
const MOBILE_VIDEOS_PAGE_SIZE = 20;
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)";
export function VideosPage() {
const [list, setList] = useState<api.AdminVideo[]>([]);
@@ -23,6 +25,11 @@ export function VideosPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchRegenOpen, setBatchRegenOpen] = useState(false);
const [batchRegening, setBatchRegening] = useState(false);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
const [deleting, setDeleting] = useState(false);
const pageSize = useVideosPageSize();
const { show } = useToast();
async function refresh() {
@@ -30,7 +37,7 @@ export function VideosPage() {
setLoadError("");
try {
const [r, tagList, driveList] = await Promise.all([
api.listVideos({ driveId, page, size: PAGE_SIZE, keyword: searchKeyword }),
api.listVideos({ driveId, page, size: pageSize, keyword: searchKeyword }),
api.listTags(),
api.listDrives(),
]);
@@ -50,7 +57,11 @@ export function VideosPage() {
useEffect(() => {
refresh();
}, [driveId, page, searchKeyword]);
}, [driveId, page, searchKeyword, pageSize]);
useEffect(() => {
setPage(1);
}, [pageSize]);
useEffect(() => {
if (keyword === searchKeyword) return;
@@ -66,9 +77,9 @@ export function VideosPage() {
);
const listItems = list;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const pageStart = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
const pageEnd = Math.min(total, page * PAGE_SIZE);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageStart = total === 0 ? 0 : (page - 1) * pageSize + 1;
const pageEnd = Math.min(total, page * pageSize);
const listSummary = driveId
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`;
@@ -87,6 +98,11 @@ export function VideosPage() {
setBatchRegenOpen(true);
}
async function handleBatchDelete() {
if (selectedIds.size === 0) return;
setBatchDeleteOpen(true);
}
async function confirmBatchRegen() {
const ids = [...selectedIds];
setBatchRegening(true);
@@ -106,6 +122,65 @@ export function VideosPage() {
}
}
async function confirmDeleteVideo() {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleting(true);
try {
const result = await api.deleteVideo(target.id);
setDeleteTarget(null);
setSelectedIds((ids) => {
const next = new Set(ids);
next.delete(target.id);
return next;
});
show(result.deletedSource ? "已删除视频,并清理 91Spider 源文件" : "已删除视频", "success");
if (listItems.length === 1 && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
refresh();
}
} catch (e) {
show(e instanceof Error ? e.message : "删除失败", "error");
} finally {
setDeleting(false);
}
}
async function confirmBatchDelete() {
const ids = [...selectedIds];
if (ids.length === 0) return;
setBatchDeleting(true);
try {
const results = await Promise.allSettled(
ids.map((id) => api.deleteVideo(id))
);
let success = 0;
let deletedSources = 0;
for (const r of results) {
if (r.status !== "fulfilled") continue;
success++;
if (r.value.deletedSource) deletedSources++;
}
const failed = ids.length - success;
if (failed === 0) {
const extra = deletedSources > 0 ? `,其中 ${deletedSources} 个清理了 91Spider 源文件` : "";
show(`批量删除完成,成功 ${success}${extra}`, "success");
} else {
show(`批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed}`, success > 0 ? "info" : "error");
}
setSelectedIds(new Set());
setBatchDeleteOpen(false);
if (success >= listItems.length && page > 1) {
setPage((p) => Math.max(1, p - 1));
} else {
refresh();
}
} finally {
setBatchDeleting(false);
}
}
const toggleSelectAll = () => {
if (selectedIds.size === listItems.length && listItems.length > 0) {
setSelectedIds(new Set());
@@ -132,22 +207,25 @@ export function VideosPage() {
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
<div className="admin-page__actions admin-videos-filter">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => {
setDriveId(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id} {d.teaserReadyCount ?? 0}{" "}
{d.teaserPendingCount ?? 0}
</option>
))}
</select>
<div className="admin-videos-filter__select-wrap">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => {
setDriveId(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id} {d.teaserReadyCount ?? 0}{" "}
{d.teaserPendingCount ?? 0}
</option>
))}
</select>
<ChevronDown size={15} className="admin-videos-filter__select-icon" aria-hidden="true" />
</div>
<form className="admin-videos-filter__search" onSubmit={handleSearchSubmit}>
<Search size={14} className="admin-videos-filter__search-icon" />
<input
@@ -163,37 +241,6 @@ export function VideosPage() {
</div>
</header>
{drives.length > 0 && (
<div className="admin-drive-teasers" aria-label="网盘预览视频统计">
{drives.map((d) => (
<button
key={d.id}
type="button"
className={`admin-drive-teaser${
driveId === d.id ? " is-active" : ""
}`}
onClick={() => {
setDriveId(d.id);
setPage(1);
}}
>
<span className="admin-drive-teaser__name">{d.name || d.id}</span>
<span className="admin-drive-teaser__metric is-ready">
{d.teaserReadyCount ?? 0}
</span>
<span className="admin-drive-teaser__metric is-pending">
{d.teaserPendingCount ?? 0}
</span>
{(d.teaserFailedCount ?? 0) > 0 && (
<span className="admin-drive-teaser__metric is-failed">
{d.teaserFailedCount}
</span>
)}
</button>
))}
</div>
)}
{!loading && (
<div className="admin-videos-list-toolbar">
<div className="admin-videos-summary">{listSummary}</div>
@@ -202,9 +249,12 @@ export function VideosPage() {
<span className="admin-videos-bulk-actions__count">
{selectedIds.size}
</span>
<button type="button" className="admin-btn is-primary" onClick={handleBatchRegen}>
<button type="button" className="admin-btn is-primary admin-videos-bulk-actions__btn" onClick={handleBatchRegen}>
<RefreshCw size={13} />
</button>
<button type="button" className="admin-btn is-danger admin-videos-bulk-actions__btn" onClick={handleBatchDelete}>
<Trash2 size={13} />
</button>
</div>
)}
</div>
@@ -236,7 +286,7 @@ export function VideosPage() {
</div>
) : (
<>
<table className="admin-table is-selectable">
<table className="admin-table is-selectable admin-videos-table">
<thead>
<tr>
<th className="is-checkbox" style={{ width: '40px' }}>
@@ -278,6 +328,7 @@ export function VideosPage() {
{fileMeta(v)}
</div>
)}
<VideoFileMetaPills video={v} />
</td>
<td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td>
<td data-label="标签">
@@ -302,6 +353,9 @@ export function VideosPage() {
</button>{" "}
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
<RefreshCw size={13} />
</button>{" "}
<button type="button" className="admin-btn is-danger" onClick={() => setDeleteTarget(v)} title="删除视频">
<Trash2 size={13} />
</button>
</td>
</tr>
@@ -326,7 +380,7 @@ export function VideosPage() {
</button>
<span className="admin-table-pagination__info">
{page} / {totalPages} {PAGE_SIZE}
{page} / {totalPages} {pageSize}
</span>
<button
type="button"
@@ -370,6 +424,34 @@ export function VideosPage() {
}}
onConfirm={confirmBatchRegen}
/>
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
message={deleteTarget ? `确定要删除「${deleteTarget.title}」吗?` : ""}
confirmText="删除视频"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
loading={deleting}
onCancel={() => {
if (!deleting) setDeleteTarget(null);
}}
onConfirm={confirmDeleteVideo}
/>
<ConfirmModal
open={batchDeleteOpen}
title="批量删除视频"
message={`确定要删除当前页选中的 ${selectedIds.size} 个视频吗?`}
confirmText="批量删除"
danger
centerMessage
modalClassName="admin-modal--delete-confirm"
loading={batchDeleting}
onCancel={() => {
if (!batchDeleting) setBatchDeleteOpen(false);
}}
onConfirm={confirmBatchDelete}
/>
</section>
);
}
@@ -381,6 +463,27 @@ function PreviewStatus({ s }: { s: string }) {
return <span className="admin-status is-pending"></span>;
}
function VideoFileMetaPills({ video }: { video: api.AdminVideo }) {
const parts = fileMetaParts(video);
const category = (video.category ?? "").trim();
if (parts.length === 0 && !category) return null;
return (
<div className="admin-video-filemeta-pills" aria-label="视频文件信息">
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="admin-video-filemeta-pill">
{part}
</span>
))}
{category && (
<span className="admin-video-filemeta-pill is-category">
{category}
</span>
)}
</div>
);
}
function formatDur(sec: number): string {
if (!sec) return "—";
const m = Math.floor(sec / 60);
@@ -388,6 +491,26 @@ function formatDur(sec: number): string {
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
function useVideosPageSize() {
const [pageSize, setPageSize] = useState(() =>
window.matchMedia(VIDEOS_MOBILE_QUERY).matches
? MOBILE_VIDEOS_PAGE_SIZE
: DESKTOP_VIDEOS_PAGE_SIZE
);
useEffect(() => {
const media = window.matchMedia(VIDEOS_MOBILE_QUERY);
const update = () => {
setPageSize(media.matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE);
};
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return pageSize;
}
function EditVideoModal({
video,
availableTags,
@@ -549,12 +672,15 @@ function EditVideoModal({
}
function fileMeta(v: api.AdminVideo): string {
const parts = [
return fileMetaParts(v).join(" · ");
}
function fileMetaParts(v: api.AdminVideo): string[] {
return [
normalizeExt(v.ext),
v.quality,
v.size > 0 ? formatBytes(v.size) : "",
].filter(Boolean);
return parts.join(" · ");
}
function normalizeExt(ext: string): string {
+7
View File
@@ -353,6 +353,13 @@ export function updateVideo(id: string, body: UpdateVideoInput) {
});
}
export function deleteVideo(id: string) {
return request<{ ok: boolean; deletedSource: boolean }>(
`/videos/${encodeURIComponent(id)}`,
{ method: "DELETE" }
);
}
export function regenPreview(id: string) {
return request<{ ok: boolean }>(
`/videos/${encodeURIComponent(id)}/regen-preview`,
+7 -33
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import * as api from "../api";
import { Modal } from "../Modal";
@@ -15,15 +15,15 @@ export function DeleteDriveModal({
}) {
const name = drive?.name || drive?.id || "";
const isSpider91 = drive?.kind === "spider91";
const isLocalStorage = drive?.kind === "localstorage";
const title = isSpider91 ? "删除 91Spider" : "删除存储";
const primaryText = deleting ? "删除中..." : "确认删除并清理";
const primaryText = deleting ? "删除中..." : "确认删除";
return (
<Modal
open={!!drive}
title={title}
onClose={onCancel}
className="admin-modal--delete-confirm"
footer={
<>
<button className="admin-btn" onClick={onCancel} disabled={deleting}>
@@ -36,37 +36,11 @@ export function DeleteDriveModal({
</>
}
>
<div className="admin-delete-confirm">
<div className="admin-delete-confirm__icon">
<AlertTriangle size={20} />
</div>
<div className="admin-delete-confirm__content">
<p className="admin-delete-confirm__title">
{isSpider91
? `确定删除「${name}」吗?`
: `确定删除「${name}」并清理该存储的视频数据吗?`}
</p>
<p className="admin-delete-confirm__text">
</p>
<ul className="admin-delete-confirm__list">
<li></li>
<li></li>
<li></li>
{isSpider91 && (
<li> 91Spider 91 </li>
)}
{isLocalStorage && (
<li></li>
)}
</ul>
{!isSpider91 && !isLocalStorage && (
<p className="admin-delete-confirm__text">
</p>
)}
<div className="admin-confirm is-message-centered">
<div className="admin-confirm__content">
<p className="admin-confirm__message">{`确定要删除「${name}」吗?`}</p>
</div>
</div>
</Modal>
);
}
}
+1 -1
View File
@@ -139,4 +139,4 @@ export function P123QRCodeLogin({ onToken }: { onToken: (token: string) => void
</div>
</div>
);
}
}
+425 -42
View File
@@ -890,48 +890,64 @@
background: var(--bg-elevated);
}
.admin-delete-confirm {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: var(--space-3);
align-items: start;
}
.admin-delete-confirm__icon {
width: 42px;
height: 42px;
border-radius: var(--radius-sm);
display: grid;
place-items: center;
color: var(--danger);
background: var(--danger-soft);
border: 1px solid rgba(241, 85, 108, 0.34);
}
.admin-delete-confirm__content {
min-width: 0;
}
.admin-delete-confirm__title {
margin: 0 0 8px;
.admin-modal--delete-confirm {
background: var(--bg-surface);
border-color: var(--border-default);
color: var(--text-strong);
font-size: var(--font-md);
font-weight: var(--weight-semibold);
box-shadow: var(--shadow-xl);
}
.admin-delete-confirm__text {
margin: 0 0 10px;
color: var(--text-muted);
font-size: var(--font-sm);
line-height: 1.6;
.admin-modal--delete-confirm .admin-modal__header,
.admin-modal--delete-confirm .admin-modal__footer {
background: var(--bg-elevated);
border-color: var(--border-subtle);
}
.admin-delete-confirm__list {
margin: 0 0 10px;
padding-left: 18px;
color: var(--text-default);
font-size: var(--font-sm);
line-height: 1.7;
.admin-modal--delete-confirm .admin-modal__header,
.admin-modal--delete-confirm .admin-confirm__message {
color: var(--text-strong);
}
.admin-modal--delete-confirm .admin-modal__header .admin-btn {
background: var(--bg-elevated);
color: var(--accent);
border-color: var(--border-default);
}
.admin-modal--delete-confirm .admin-modal__header .admin-btn:hover:not(:disabled) {
background: var(--bg-surface);
color: var(--accent-hover);
border-color: var(--border-strong);
}
:root[data-theme="pink"] .admin-modal--delete-confirm {
background: #ffffff;
border-color: rgba(255, 91, 138, 0.18);
color: #2a1820;
box-shadow: 0 18px 54px rgba(42, 24, 32, 0.18);
}
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header,
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__footer {
background: #ffffff;
border-color: rgba(255, 91, 138, 0.12);
}
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header,
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-confirm__message {
color: #2a1820;
}
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header .admin-btn {
background: #ffffff;
color: #ff5b8a;
border-color: rgba(255, 91, 138, 0.26);
}
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header .admin-btn:hover:not(:disabled) {
background: #fff6f9;
color: #f43d75;
border-color: rgba(255, 91, 138, 0.45);
}
.admin-confirm {
@@ -962,6 +978,19 @@
min-width: 0;
}
.admin-confirm.is-message-centered {
grid-template-columns: minmax(0, 1fr);
}
.admin-confirm.is-message-centered .admin-confirm__icon {
display: none;
}
.admin-confirm.is-message-centered .admin-confirm__message {
margin-bottom: 0;
text-align: left;
}
.admin-confirm__message {
margin: 0 0 10px;
color: var(--text-strong);
@@ -1596,6 +1625,11 @@
max-height: calc(100vh - 16px);
}
.admin-modal--delete-confirm {
align-self: center;
justify-self: center;
}
.admin-modal__header,
.admin-modal__body,
.admin-modal__footer {
@@ -1648,6 +1682,298 @@
font-size: var(--font-xs);
}
.admin-video-filemeta-pills {
display: none;
}
@media (max-width: 768px) {
.admin-videos-table:not(.admin-drives-table) tbody {
gap: 10px;
}
.admin-videos-table:not(.admin-drives-table) tr {
--admin-video-card-bg: var(--bg-surface);
--admin-video-card-border: var(--border-default);
--admin-video-card-line: var(--border-subtle);
--admin-video-card-shadow: var(--shadow-sm);
--admin-video-card-selected-shadow: 0 0 0 1px var(--border-accent), var(--shadow-md);
--admin-video-card-main: var(--text-strong);
--admin-video-card-text: var(--text-default);
--admin-video-card-muted: var(--text-muted);
--admin-video-card-faint: var(--text-faint);
--admin-video-card-pill-bg: var(--bg-elevated);
--admin-video-card-pill-border: var(--border-subtle);
--admin-video-card-pill-text: var(--text-muted);
--admin-video-card-category-bg: var(--accent-soft);
--admin-video-card-category-border: var(--border-accent);
--admin-video-card-category-text: var(--accent);
--admin-video-card-button-bg: var(--bg-elevated);
--admin-video-card-button-hover-bg: var(--bg-surface);
--admin-video-card-button-text: var(--text-default);
--admin-video-card-danger: var(--danger);
--admin-video-card-danger-border: rgba(241, 85, 108, 0.4);
position: relative;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
padding: 12px 14px;
background: var(--admin-video-card-bg);
border: 1px solid var(--admin-video-card-border);
border-radius: 14px;
box-shadow: var(--admin-video-card-shadow);
color: var(--admin-video-card-text);
}
:root:not([data-theme="pink"]) .admin-videos-table:not(.admin-drives-table) tr {
--admin-video-card-bg: #1e1e1e;
--admin-video-card-border: rgba(255, 255, 255, 0.06);
--admin-video-card-line: rgba(255, 255, 255, 0.08);
--admin-video-card-shadow: 0 8px 20px rgba(0, 0, 0, 0.22);
--admin-video-card-selected-shadow:
0 0 0 1px rgba(255, 138, 60, 0.28),
0 8px 20px rgba(0, 0, 0, 0.24);
--admin-video-card-main: #f0f0f0;
--admin-video-card-text: #e0e0e0;
--admin-video-card-muted: rgba(255, 255, 255, 0.62);
--admin-video-card-faint: rgba(255, 255, 255, 0.3);
--admin-video-card-pill-bg: rgba(255, 255, 255, 0.06);
--admin-video-card-pill-border: rgba(255, 255, 255, 0.05);
--admin-video-card-pill-text: rgba(255, 255, 255, 0.62);
--admin-video-card-category-bg: rgba(255, 255, 255, 0.11);
--admin-video-card-category-border: rgba(255, 255, 255, 0.14);
--admin-video-card-category-text: #f0f0f0;
--admin-video-card-button-bg: rgba(255, 255, 255, 0.06);
--admin-video-card-button-hover-bg: rgba(255, 255, 255, 0.1);
--admin-video-card-button-text: #e0e0e0;
--admin-video-card-danger: #f08080;
--admin-video-card-danger-border: rgba(240, 80, 80, 0.4);
}
:root[data-theme="pink"] .admin-videos-table:not(.admin-drives-table) tr {
--admin-video-card-danger-border: rgba(228, 59, 92, 0.36);
}
.admin-videos-table:not(.admin-drives-table) tr.is-selected {
background: var(--admin-video-card-bg);
border-color: var(--border-accent);
box-shadow: var(--admin-video-card-selected-shadow);
}
.admin-videos-table:not(.admin-drives-table) td {
gap: 3px;
color: var(--admin-video-card-text);
font-size: 12px;
line-height: 1.35;
}
.admin-videos-table:not(.admin-drives-table) td::before {
color: var(--admin-video-card-faint);
font-size: 10px;
font-weight: var(--weight-semibold);
letter-spacing: 0.06em;
line-height: 1;
text-transform: uppercase;
}
.admin-videos-table:not(.admin-drives-table) td.is-checkbox {
position: absolute;
top: 12px;
left: 14px;
z-index: 1;
display: flex;
width: 28px;
height: 28px;
}
.admin-videos-table:not(.admin-drives-table) td.is-checkbox::before,
.admin-videos-table:not(.admin-drives-table) td[data-label="标题"]::before {
content: none;
display: none;
}
.admin-videos-table:not(.admin-drives-table) .admin-table-checkbox-btn {
border-radius: 8px;
color: var(--admin-video-card-muted);
}
.admin-videos-table:not(.admin-drives-table) .admin-table-checkbox-btn:hover,
.admin-videos-table:not(.admin-drives-table) .admin-table-checkbox-btn:focus-visible {
background: var(--admin-video-card-button-hover-bg);
color: var(--admin-video-card-main);
}
.admin-videos-table:not(.admin-drives-table) td[data-label="标题"] {
grid-column: 1 / -1;
grid-row: 1;
min-height: 28px;
padding-left: 36px;
gap: 6px;
align-content: center;
}
.admin-videos-table:not(.admin-drives-table) .admin-video-title {
color: var(--admin-video-card-main);
font-size: 14px;
font-weight: var(--weight-bold);
line-height: 1.35;
}
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta {
display: none;
}
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pill {
display: inline-flex;
align-items: center;
min-height: 19px;
padding: 2px 7px;
border: 1px solid var(--admin-video-card-pill-border);
border-radius: var(--radius-pill);
background: var(--admin-video-card-pill-bg);
color: var(--admin-video-card-pill-text);
font-size: 10px;
font-weight: var(--weight-semibold);
line-height: 1;
}
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pill.is-category {
border-color: var(--admin-video-card-category-border);
background: var(--admin-video-card-category-bg);
color: var(--admin-video-card-category-text);
}
.admin-videos-table:not(.admin-drives-table) td[data-label="标签"] {
display: none;
}
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"],
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"],
.admin-videos-table:not(.admin-drives-table) td[data-label="预览视频"] {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--admin-video-card-line);
}
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"] {
grid-column: 1;
padding-right: 12px;
}
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"],
.admin-videos-table:not(.admin-drives-table) td[data-label="预览视频"] {
grid-column: 2;
padding-left: 12px;
}
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"] {
grid-row: 2;
}
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"],
.admin-videos-table:not(.admin-drives-table) td[data-label="预览视频"] {
grid-row: 3;
}
.admin-videos-table:not(.admin-drives-table) .admin-mono-cell {
color: var(--admin-video-card-text);
font-family: inherit;
font-size: 12px;
word-break: break-word;
}
.admin-videos-table:not(.admin-drives-table) .admin-text-faint {
color: var(--admin-video-card-faint);
}
.admin-videos-table:not(.admin-drives-table) .admin-status {
width: max-content;
min-height: 22px;
padding: 3px 8px;
border-radius: var(--radius-pill);
font-size: 11px;
line-height: 1;
}
.admin-videos-table:not(.admin-drives-table) .admin-status.is-ok {
background: var(--success-soft);
color: var(--success);
}
:root:not([data-theme="pink"]) .admin-videos-table:not(.admin-drives-table) .admin-status.is-ok {
background: rgba(110, 231, 183, 0.12);
color: #6ee7b7;
}
.admin-videos-table:not(.admin-drives-table) td.is-actions {
grid-column: 1 / -1;
grid-row: 4;
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--admin-video-card-line);
}
.admin-videos-table:not(.admin-drives-table) td.is-actions::before {
flex: 0 0 auto;
margin-right: auto;
}
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn {
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0 9px;
border-color: transparent;
border-radius: 8px;
background: var(--admin-video-card-button-bg);
box-shadow: none;
color: var(--admin-video-card-button-text);
font-size: 12px;
gap: 5px;
}
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn + .admin-btn {
margin-left: 0;
}
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn:not(:first-of-type) {
width: 28px;
padding: 0;
}
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn:hover:not(:disabled) {
background: var(--admin-video-card-button-hover-bg);
border-color: transparent;
box-shadow: none;
color: var(--admin-video-card-main);
}
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn.is-danger {
border-color: var(--admin-video-card-danger-border);
background: transparent;
color: var(--admin-video-card-danger);
}
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn.is-danger:hover:not(:disabled) {
border-color: var(--admin-video-card-danger);
background: var(--danger-soft);
color: var(--admin-video-card-danger);
}
}
.admin-loading-screen {
min-height: 100vh;
display: grid;
@@ -1672,24 +1998,50 @@
flex-wrap: wrap;
}
.admin-videos-filter__select {
height: 38px;
.admin-videos-filter__select-wrap {
position: relative;
display: inline-flex;
min-width: 200px;
padding: 0 var(--space-3);
}
.admin-videos-filter__select {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 38px;
padding: 0 36px 0 var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
background: var(--bg-sunken);
color: var(--text-strong);
font-size: var(--font-sm);
line-height: 38px;
cursor: pointer;
}
.admin-videos-filter__select::-ms-expand {
display: none;
}
.admin-videos-filter__select:focus {
outline: none;
border-color: var(--border-accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.admin-videos-filter__select-icon {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
color: var(--text-faint);
pointer-events: none;
}
.admin-videos-filter__select:focus + .admin-videos-filter__select-icon {
color: var(--accent);
}
.admin-videos-filter__select option {
background: var(--bg-elevated);
color: var(--text-strong);
@@ -1758,6 +2110,35 @@
white-space: nowrap;
}
.admin-videos-bulk-actions__btn {
box-shadow: none;
}
.admin-videos-bulk-actions__btn.is-primary {
box-shadow: 0 2px 10px var(--accent-glow);
}
.admin-videos-bulk-actions__btn.is-primary:hover:not(:disabled) {
box-shadow: 0 3px 12px var(--accent-glow);
}
.admin-videos-bulk-actions__btn.is-danger {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger);
}
.admin-videos-bulk-actions__btn.is-danger:hover:not(:disabled) {
background: var(--danger);
border-color: var(--danger);
color: #ffffff;
box-shadow: 0 3px 12px var(--danger-soft);
}
.admin-videos-bulk-actions__btn.is-danger:focus-visible {
outline-color: var(--danger);
}
.admin-table-pagination {
display: flex;
flex-wrap: wrap;
@@ -1790,7 +2171,7 @@
}
@media (max-width: 768px) {
.admin-videos-filter__select,
.admin-videos-filter__select-wrap,
.admin-videos-filter__search {
flex: 1 1 100%;
min-width: 0;
@@ -2971,4 +3352,6 @@
@media (hover: hover) and (pointer: fine) {
.admin-table tbody tr:hover td { background: var(--bg-elevated) !important; }
}
.admin-table tr.is-selected td { background: var(--accent-softer) !important; }
@media (min-width: 769px) {
.admin-table tr.is-selected td { background: var(--accent-softer) !important; }
}
+14
View File
@@ -179,6 +179,20 @@ test("drive detail selection is stored in the URL history", () => {
assert.doesNotMatch(drivesPageSource, /setSelectedDriveId/);
});
test("drive discard confirmation matches delete confirmation modal styling", () => {
const discardModals = Array.from(
drivesPageSource.matchAll(/<ConfirmModal[\s\S]*?title="放弃未保存更改"[\s\S]*?\/>/g),
(match) => match[0]
);
assert.equal(discardModals.length, 2);
for (const modal of discardModals) {
assert.match(modal, /danger/);
assert.match(modal, /centerMessage/);
assert.match(modal, /modalClassName="admin-modal--delete-confirm"/);
}
});
test("drive generation actions can resume pending work after stop", () => {
assert.match(driveComponentsSource, /thumbnailPendingCount/);
assert.match(driveComponentsSource, /teaserPendingCount/);
+65
View File
@@ -6,6 +6,10 @@ const adminCss = readFileSync(
new URL("../src/styles/admin.css", import.meta.url),
"utf8"
);
const videosPageSource = readFileSync(
new URL("../src/admin/VideosPage.tsx", import.meta.url),
"utf8"
);
function ruleBody(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -66,6 +70,63 @@ test("admin tables scroll inside the mobile viewport", () => {
assert.match(body, /display\s*:\s*block/);
});
test("admin video filter select uses an aligned custom arrow", () => {
const select = ruleBody(adminCss, ".admin-videos-filter__select");
const icon = ruleBody(adminCss, ".admin-videos-filter__select-icon");
const mobileWrap = ruleBodyByContains(mobileCss(), ".admin-videos-filter__select-wrap");
assert.match(select, /appearance\s*:\s*none/);
assert.match(select, /padding\s*:\s*0\s+36px\s+0\s+var\(--space-3\)/);
assert.match(icon, /top\s*:\s*50%/);
assert.match(icon, /right\s*:\s*12px/);
assert.match(icon, /transform\s*:\s*translateY\(-50%\)/);
assert.match(mobileWrap, /flex\s*:\s*1\s+1\s+100%/);
});
test("admin video bulk actions use semantic theme colors", () => {
const base = ruleBody(adminCss, ".admin-videos-bulk-actions__btn");
const primary = ruleBody(adminCss, ".admin-videos-bulk-actions__btn.is-primary");
const danger = ruleBody(adminCss, ".admin-videos-bulk-actions__btn.is-danger");
const dangerHover = ruleBody(adminCss, ".admin-videos-bulk-actions__btn.is-danger:hover:not(:disabled)");
const bulkBodies = [base, primary, danger, dangerHover].join("\n");
assert.match(videosPageSource, /className="admin-btn is-primary admin-videos-bulk-actions__btn"/);
assert.match(videosPageSource, /className="admin-btn is-danger admin-videos-bulk-actions__btn"/);
assert.match(primary, /var\(--accent-glow\)/);
assert.match(danger, /background\s*:\s*var\(--danger-soft\)/);
assert.match(danger, /border-color\s*:\s*var\(--danger\)/);
assert.match(danger, /color\s*:\s*var\(--danger\)/);
assert.match(dangerHover, /background\s*:\s*var\(--danger\)/);
assert.doesNotMatch(bulkBodies, /#ff5b8a|#fff6f9|rgba\(255,\s*91,\s*138/);
});
test("mobile video management uses compact theme-aware video cards", () => {
const css = mobileCss();
const card = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) tr");
const title = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td[data-label=\"标题\"]");
const label = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td::before");
const pills = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pills");
const sourceColumn = ruleBodyByContains(css, ".admin-videos-table:not(.admin-drives-table) td[data-label=\"来源\"]");
const actionButton = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn");
const dangerButton = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn.is-danger");
assert.match(card, /--admin-video-card-bg\s*:\s*var\(--bg-surface\)/);
assert.match(card, /background\s*:\s*var\(--admin-video-card-bg\)/);
assert.match(card, /border-radius\s*:\s*14px/);
assert.match(card, /padding\s*:\s*12px\s+14px/);
assert.match(css, /:root:not\(\[data-theme="pink"\]\)\s+\.admin-videos-table:not\(\.admin-drives-table\)\s+tr\s*\{[^}]*--admin-video-card-bg\s*:\s*#1e1e1e/s);
assert.match(css, /:root\[data-theme="pink"\]\s+\.admin-videos-table:not\(\.admin-drives-table\)\s+tr\s*\{/);
assert.match(title, /padding-left\s*:\s*36px/);
assert.match(label, /font-size\s*:\s*10px/);
assert.match(label, /letter-spacing\s*:\s*0\.06em/);
assert.match(pills, /display\s*:\s*flex/);
assert.doesNotMatch(sourceColumn, /border-left/);
assert.match(actionButton, /height\s*:\s*28px/);
assert.match(actionButton, /border-radius\s*:\s*8px/);
assert.match(dangerButton, /border-color\s*:\s*var\(--admin-video-card-danger-border\)/);
assert.match(dangerButton, /color\s*:\s*var\(--admin-video-card-danger\)/);
});
test("admin modals and action footers adapt on mobile", () => {
const css = mobileCss();
@@ -74,6 +135,10 @@ test("admin modals and action footers adapt on mobile", () => {
assert.match(ruleBody(adminCss, ".admin-modal"), /width\s*:\s*min\(\d+px,\s*100%\)/);
// 多按钮 footer 在 mobile 下要换行避免溢出。
assert.match(allRuleBodies(css, ".admin-modal__footer"), /flex-wrap\s*:\s*wrap/);
// 删除/放弃类确认弹窗在 mobile 下不能跟随通用 modal stretch 到顶部。
const confirmModal = ruleBody(css, ".admin-modal--delete-confirm");
assert.match(confirmModal, /align-self\s*:\s*center/);
assert.match(confirmModal, /justify-self\s*:\s*center/);
// 表单 input/select/textarea 在 mobile 下铺满。规则用逗号合并写法(多 selector
// 共享 body),所以走 ruleBodyByContains 而不是简单正则。
assert.match(ruleBodyByContains(css, ".admin-form__row input"), /width\s*:\s*100%/);
+13
View File
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
const videosPageSource = readFileSync(new URL("../src/admin/VideosPage.tsx", import.meta.url), "utf8");
test("admin videos page uses responsive page size", () => {
assert.match(videosPageSource, /const DESKTOP_VIDEOS_PAGE_SIZE = 50;/);
assert.match(videosPageSource, /const MOBILE_VIDEOS_PAGE_SIZE = 20;/);
assert.match(videosPageSource, /const VIDEOS_MOBILE_QUERY = "\(max-width: 640px\)";/);
assert.match(videosPageSource, /window\.matchMedia\(VIDEOS_MOBILE_QUERY\)/);
assert.match(videosPageSource, /api\.listVideos\(\{ driveId, page, size: pageSize, keyword: searchKeyword \}\)/);
});