feat: bookmark folder reordering and add-existing-link
Add up/down arrow buttons to reorder links within a folder, with lazy sort_order initialization. Add dropdown to move existing links into the current folder. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,8 @@ async def list_bookmarks(
|
|||||||
current_folder = f
|
current_folder = f
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get links (filtered by folder or all unfiled)
|
# Get links (filtered by folder or all)
|
||||||
|
available_links = []
|
||||||
if folder_id:
|
if folder_id:
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT l.* FROM links l
|
SELECT l.* FROM links l
|
||||||
@@ -69,6 +70,16 @@ async def list_bookmarks(
|
|||||||
WHERE fl.folder_id = :fid AND l.is_deleted = false
|
WHERE fl.folder_id = :fid AND l.is_deleted = false
|
||||||
ORDER BY fl.sort_order, l.label
|
ORDER BY fl.sort_order, l.label
|
||||||
"""), {"fid": folder_id})
|
"""), {"fid": folder_id})
|
||||||
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Links NOT in this folder (for "add existing" dropdown)
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.id, l.label FROM links l
|
||||||
|
WHERE l.is_deleted = false
|
||||||
|
AND l.id NOT IN (SELECT link_id FROM folder_links WHERE folder_id = :fid)
|
||||||
|
ORDER BY l.label
|
||||||
|
"""), {"fid": folder_id})
|
||||||
|
available_links = [dict(r._mapping) for r in result]
|
||||||
else:
|
else:
|
||||||
# Show all links
|
# Show all links
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
@@ -76,13 +87,14 @@ async def list_bookmarks(
|
|||||||
WHERE l.is_deleted = false
|
WHERE l.is_deleted = false
|
||||||
ORDER BY l.sort_order, l.label
|
ORDER BY l.sort_order, l.label
|
||||||
"""))
|
"""))
|
||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("weblinks.html", {
|
return templates.TemplateResponse("weblinks.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
"top_folders": top_folders, "child_folder_map": child_folder_map,
|
"top_folders": top_folders, "child_folder_map": child_folder_map,
|
||||||
"current_folder": current_folder,
|
"current_folder": current_folder,
|
||||||
"current_folder_id": folder_id or "",
|
"current_folder_id": folder_id or "",
|
||||||
|
"available_links": available_links,
|
||||||
"page_title": "Bookmarks", "active_nav": "bookmarks",
|
"page_title": "Bookmarks", "active_nav": "bookmarks",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -220,6 +232,96 @@ async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
return RedirectResponse(url=referer, status_code=303)
|
return RedirectResponse(url=referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Reorder links within a folder ----
|
||||||
|
|
||||||
|
@router.post("/folders/{folder_id}/reorder")
|
||||||
|
async def reorder_link(
|
||||||
|
folder_id: str,
|
||||||
|
link_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Get all folder_links for this folder, ordered by sort_order then created_at
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT link_id, sort_order FROM folder_links
|
||||||
|
WHERE folder_id = :fid
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"fid": folder_id})
|
||||||
|
rows = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
# Lazy-initialize sort_order if all zeros
|
||||||
|
all_zero = all(r["sort_order"] == 0 for r in rows)
|
||||||
|
if all_zero:
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE folder_links SET sort_order = :so
|
||||||
|
WHERE folder_id = :fid AND link_id = :lid
|
||||||
|
"""), {"so": (i + 1) * 10, "fid": folder_id, "lid": r["link_id"]})
|
||||||
|
r["sort_order"] = (i + 1) * 10
|
||||||
|
|
||||||
|
# Find target index
|
||||||
|
idx = None
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
if str(r["link_id"]) == link_id:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if idx is None:
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
# Determine swap partner
|
||||||
|
if direction == "up" and idx > 0:
|
||||||
|
swap_idx = idx - 1
|
||||||
|
elif direction == "down" and idx < len(rows) - 1:
|
||||||
|
swap_idx = idx + 1
|
||||||
|
else:
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
# Swap sort_order values
|
||||||
|
so_a, so_b = rows[idx]["sort_order"], rows[swap_idx]["sort_order"]
|
||||||
|
lid_a, lid_b = rows[idx]["link_id"], rows[swap_idx]["link_id"]
|
||||||
|
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE folder_links SET sort_order = :so
|
||||||
|
WHERE folder_id = :fid AND link_id = :lid
|
||||||
|
"""), {"so": so_b, "fid": folder_id, "lid": lid_a})
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE folder_links SET sort_order = :so
|
||||||
|
WHERE folder_id = :fid AND link_id = :lid
|
||||||
|
"""), {"so": so_a, "fid": folder_id, "lid": lid_b})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Add existing link to folder ----
|
||||||
|
|
||||||
|
@router.post("/folders/{folder_id}/add-link")
|
||||||
|
async def add_link_to_folder(
|
||||||
|
folder_id: str,
|
||||||
|
link_id: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Remove link from any existing folder (single-folder membership)
|
||||||
|
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
|
||||||
|
|
||||||
|
# Get max sort_order in target folder
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT COALESCE(MAX(sort_order), 0) FROM folder_links WHERE folder_id = :fid
|
||||||
|
"""), {"fid": folder_id})
|
||||||
|
max_so = result.scalar()
|
||||||
|
|
||||||
|
# Insert into target folder at end
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO folder_links (folder_id, link_id, sort_order)
|
||||||
|
VALUES (:fid, :lid, :so) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"fid": folder_id, "lid": link_id, "so": max_so + 10})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
# ---- Folders ----
|
# ---- Folders ----
|
||||||
|
|
||||||
@router.get("/folders/create")
|
@router.get("/folders/create")
|
||||||
|
|||||||
@@ -38,6 +38,23 @@
|
|||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% if available_links %}
|
||||||
|
<div class="card" style="margin-bottom: 12px;">
|
||||||
|
<form action="/weblinks/folders/{{ current_folder.id }}/add-link" method="post"
|
||||||
|
style="display: flex; gap: 8px; align-items: end; padding: 12px;">
|
||||||
|
<div class="form-group" style="flex: 1; margin: 0;">
|
||||||
|
<label class="form-label">Add existing link</label>
|
||||||
|
<select name="link_id" class="form-select" required>
|
||||||
|
<option value="">Select link...</option>
|
||||||
|
{% for l in available_links %}
|
||||||
|
<option value="{{ l.id }}">{{ l.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
@@ -56,6 +73,18 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
{% if current_folder_id %}
|
||||||
|
<form action="/weblinks/folders/{{ current_folder_id }}/reorder" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="link_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" title="Move up">▲</button>
|
||||||
|
</form>
|
||||||
|
<form action="/weblinks/folders/{{ current_folder_id }}/reorder" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="link_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" title="Move down">▼</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||||
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this link?" style="display:inline">
|
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this link?" style="display:inline">
|
||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user