rename items, style fix

AuthorKonata <konata@posteo.jp>
Date
Commit696b93381bc9940e6a57da7de5728ecf1a7ce14c
Parentf23aa49
7 files changed, 134 insertions(+), 20 deletions(-)
▾Minternal/handler/library.go
@@ -11,6 +11,7 @@ import (
"vidarchive"
"vidarchive/internal/models"
"vidarchive/internal/service"
"vidarchive/internal/util"
)
func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
@@ -76,6 +77,11 @@ func (h *Handler) LibraryItem(w http.ResponseWriter, r *http.Request) {
h.deleteMedia(relPath, w, r)
return
}
if r.Method == "POST" && strings.HasSuffix(relPath, "/rename") {
relPath = strings.TrimSuffix(relPath, "/rename")
h.renameMedia(relPath, w, r)
return
}
h.libraryDetail(relPath, w, r)
}
@@ -250,6 +256,19 @@ func (h *Handler) serveSubtitles(relPath, lang string, w http.ResponseWriter, r
http.ServeFile(w, r, subtitlePath)
}
func (h *Handler) renameMedia(relPath string, w http.ResponseWriter, r *http.Request) {
if !parseForm(w, r) {
return
}
itemURL := "/library/item/" + util.URLEncodePath(relPath)
if err := h.librarySvc.Rename(relPath, r.FormValue("name")); err != nil {
redirectWithError(w, r, itemURL, "Couldn't rename this item.", err)
return
}
redirectWithSuccess(w, r, itemURL, "Item renamed.")
}
func (h *Handler) deleteMedia(relPath string, w http.ResponseWriter, r *http.Request) {
if err := h.librarySvc.Delete(relPath); err != nil {
redirectWithError(w, r, "/library", "Couldn't delete this item.", err)
▾Minternal/service/library.go
@@ -565,6 +565,33 @@ func (s *LibraryService) GetMediaFile(relPath, filename string) (string, error)
return "", fmt.Errorf("media file not found")
}
// Rename changes only the display name in the marker. The directory keeps its
// name, so existing URLs and subscription matching are unaffected.
func (s *LibraryService) Rename(relPath, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("name must not be empty")
}
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
return err
}
if _, err := os.Stat(filepath.Join(itemDir, itemMarkerName)); err != nil {
return fmt.Errorf("item not found")
}
metadata, err := s.readMetadata(itemDir)
if err != nil {
return err
}
metadata.Name = name
if err := s.writeMetadata(itemDir, metadata); err != nil {
return err
}
// Evict so the new name shows immediately instead of at TTL expiry.
s.evictCachedScan(strings.Trim(relPath, "/"))
return nil
}
func (s *LibraryService) Delete(relPath string) error {
itemDir, err := s.resolveItemDir(relPath)
if err != nil {
▾Minternal/service/library_test.go
@@ -481,3 +481,39 @@ func TestDeleteRejectsLibraryRoot(t *testing.T) {
}
// --- Thumbnail behavior ---
// --- rename ---
func TestRename(t *testing.T) {
svc, dir := newLibrary(t)
writeItem(t, dir, "vid", "name = \"B196\"\n", map[string]string{"v.mp4": "v"})
// Prime the cache so the eviction below is actually exercised.
if _, err := svc.GetByRelPath("vid"); err != nil {
t.Fatalf("prime: %v", err)
}
if err := svc.Rename("vid", " Motorrad "); err != nil {
t.Fatalf("Rename: %v", err)
}
item, err := svc.GetByRelPath("vid")
if err != nil {
t.Fatalf("lookup after rename: %v", err)
}
if item.Name != "Motorrad" {
t.Errorf("name = %q, want %q (trimmed, cache evicted)", item.Name, "Motorrad")
}
// The directory keeps its name, so existing URLs still resolve.
if _, err := os.Stat(filepath.Join(dir, "vid", "v.mp4")); err != nil {
t.Errorf("item dir should be untouched: %v", err)
}
if err := svc.Rename("vid", " "); err == nil {
t.Error("blank name should be rejected")
}
if err := svc.Rename("missing", "X"); err == nil {
t.Error("unknown item should be rejected")
}
if err := svc.Rename("../escape", "X"); err == nil {
t.Error("traversal path should be rejected")
}
}
▾Mweb/static/style.css
@@ -742,6 +742,12 @@ button:disabled {
z-index: 2;
}
.download-status-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-badge {
padding: 0.2rem 0.6rem;
border-radius: 12px;
@@ -893,25 +899,31 @@ details[open] .cancel-text {
display: inline;
}
/* Delete overlay popover */
.delete-overlay {
.detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Overlay popover */
.overlay {
position: relative;
}
.delete-overlay summary {
.overlay summary {
list-style: none;
cursor: pointer;
}
.delete-overlay summary::-webkit-details-marker {
.overlay summary::-webkit-details-marker {
display: none;
}
.delete-overlay summary::after {
.overlay summary::after {
display: none !important;
}
.delete-overlay .overlay-panel {
.overlay .overlay-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
@@ -925,7 +937,7 @@ details[open] .cancel-text {
}
/* Popover arrow */
.delete-overlay .overlay-panel::before {
.overlay .overlay-panel::before {
content: '';
position: absolute;
top: -6px;
@@ -938,12 +950,12 @@ details[open] .cancel-text {
transform: rotate(45deg);
}
.delete-overlay .overlay-panel p {
.overlay .overlay-panel p {
margin-bottom: 1rem;
font-size: 0.9rem;
}
.delete-overlay .overlay-panel .form-actions {
.overlay .overlay-panel .form-actions {
margin-top: 0;
justify-content: flex-end;
}
@@ -1210,6 +1222,12 @@ input[name="cron_expr"] {
display: inline;
}
/* Pico stretches submit buttons to 100%; inline forms want intrinsic width. */
.inline button {
width: auto;
margin-bottom: 0;
}
/* Responsive */
@media (max-width: 768px) {
main {
▾Mweb/templates/library_detail.html
@@ -2,15 +2,29 @@
<div class="detail-view">
<div class="detail-header">
<h2>{{.Data.Item.Name}}</h2>
<details class="delete-overlay">
<summary role="button" class="small danger">Delete</summary>
<div class="overlay-panel">
<p>Are you sure you want to delete this item?</p>
<form method="post" action="/library/item/{{urlEncodePath .Data.Item.RelPath}}/delete" class="form-actions">
<button type="submit" class="danger">Yes, Delete</button>
</form>
</div>
</details>
<div class="detail-actions">
<details class="overlay">
<summary role="button" class="small secondary">Edit</summary>
<div class="overlay-panel">
<form method="post" action="/library/item/{{urlEncodePath .Data.Item.RelPath}}/rename">
<label for="item-name">Name</label>
<input type="text" id="item-name" name="name" value="{{.Data.Item.Name}}" required autocomplete="off">
<div class="form-actions">
<button type="submit">Save</button>
</div>
</form>
</div>
</details>
<details class="overlay">
<summary role="button" class="small danger">Delete</summary>
<div class="overlay-panel">
<p>Are you sure you want to delete this item?</p>
<form method="post" action="/library/item/{{urlEncodePath .Data.Item.RelPath}}/delete" class="form-actions">
<button type="submit" class="danger">Yes, Delete</button>
</form>
</div>
</details>
</div>
</div>
{{if gt (len .Data.Item.MediaFiles) 1}}
▾Mweb/templates/settings.html
@@ -59,7 +59,7 @@
<article>
<div class="preset-header">
<h4>{{.Name}} {{if .IsDefault}}<span class="badge">default</span>{{end}}</h4>
<details class="delete-overlay">
<details class="overlay">
<summary role="button" class="small danger">Delete</summary>
<div class="overlay-panel">
<p>Are you sure you want to delete preset <strong>{{.Name}}</strong>?</p>
▾Mweb/templates/subscriptions.html
@@ -14,7 +14,7 @@
{{.Name}}
{{if .Enabled}}<span class="badge">active</span>{{else}}<span class="badge">paused</span>{{end}}
</h4>
<details class="delete-overlay">
<details class="overlay">
<summary role="button" class="small danger">Delete</summary>
<div class="overlay-panel">
<p>Delete subscription <strong>{{.Name}}</strong>? (Downloaded files are kept.)</p>