feat: tags end-to-end (CRUD, photo membership, filter, sidebar UI)

The Tag model and photo_tags join table were already in place; this
fills in the rest — full backend CRUD, per-photo add/remove, list-
endpoint filtering, and a Tags section in the RightSidebar with
autocomplete-create.

Backend
- routers/tags.py rewritten from a 27-line stub:
    GET    /tags                — list with photo counts
    POST   /tags                — create (idempotent on name)
    PATCH  /tags/{id}           — rename / recolor
    DELETE /tags/{id}           — delete (FK cascades photo_tags)
- routers/photos.py:
    POST   /photos/{id}/tags    — add tag ids (idempotent)
    DELETE /photos/{id}/tags/{tag_id} — remove
    GET    /photos/{id}         — now returns a `tags` list alongside
                                  the existing PhotoResponse fields
                                  (fetched via the photo_tags join)
- list_photos applies the existing tag_ids query param: comma-
  separated, AND semantics, one IN-subquery per id since SQLite
  has no native set-contains-all.

Frontend
- New hooks/useTagsQuery.ts.
- services/api.ts: Tag interface, full tags client (list/create/
  update/delete), addToPhoto/removeFromPhoto helpers.
- filterStore: tagIds: string[] field, setTagIds, toggleTagId,
  hasActiveFilters update, filtersToParams sends tag_ids comma list.
- useFilterUrlSync round-trips ?tag_ids=… so tag-filtered views
  are bookmarkable.
- usePhotosQuery threads tagIds through.
- RightSidebar gains a new Tags section using a TagsEditor
  component:
    - shows existing tag chips with X to remove
    - autocomplete input that matches the user's typing against
      existing tag names
    - shows an inline "+ Create '<name>'" affordance when there's
      no exact match
    - Enter creates and attaches in one shot; Esc clears the input
    - existing colour values render as a tinted chip background
- FilterBar gets a Tags group (only rendered when there's at
  least one tag) with toggleable chips per tag.
- ActiveFilterChips shows "Tag: <name>" chips for each active
  tag id, looking up names lazily from the tags query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:20:32 +02:00
parent 63383ecf1c
commit bc1e63095c
10 changed files with 480 additions and 32 deletions

View File

@@ -16,9 +16,10 @@ import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.models import Photo, Folder, Tag
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
from app.models.tags import photo_tags
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
@@ -120,6 +121,19 @@ async def list_photos(
)
)
# Tag filter — comma-separated tag ids, AND semantics. A photo must
# have a row in photo_tags for EVERY listed tag. Implemented as one
# subquery per tag id since SQLite doesn't have an efficient
# "set-contains-all" operator.
if tag_ids:
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
for tid in tag_id_list:
filters.append(
Photo.id.in_(
select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid)
)
)
# Apply all filters
if filters:
query = query.where(and_(*filters))
@@ -153,21 +167,89 @@ async def list_photos(
pages=(total + per_page - 1) // per_page
)
@router.get("/{photo_id}", response_model=PhotoResponse)
@router.get("/{photo_id}")
async def get_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get single photo with full EXIF and tags"""
"""Get single photo with full EXIF and its tags."""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
return PhotoResponse.from_orm(photo)
# Fetch tags via the join table so we don't need to declare a
# relationship on the Photo model side.
tag_result = await db.execute(
select(Tag)
.join(photo_tags, Tag.id == photo_tags.c.tag_id)
.where(photo_tags.c.photo_id == photo_id)
.order_by(Tag.name.asc())
)
tags = tag_result.scalars().all()
base = PhotoResponse.from_orm(photo).dict()
base["tags"] = [
{"id": t.id, "name": t.name, "color": t.color} for t in tags
]
return base
@router.post("/{photo_id}/tags", status_code=201)
async def add_photo_tags(
photo_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
Idempotent: re-adding existing members is a no-op."""
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
if photo_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Photo not found")
tag_ids = body.get("tag_ids") or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "added": 0}
existing = await db.execute(
select(photo_tags.c.tag_id).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.tag_id.in_(tag_ids),
)
)
existing_ids = {row[0] for row in existing.all()}
new_ids = [tid for tid in tag_ids if tid not in existing_ids]
if new_ids:
from sqlalchemy import insert
await db.execute(
insert(photo_tags),
[{"photo_id": photo_id, "tag_id": tid} for tid in new_ids],
)
await db.commit()
return {"status": "success", "added": len(new_ids)}
@router.delete("/{photo_id}/tags/{tag_id}", status_code=204)
async def remove_photo_tag(
photo_id: str,
tag_id: str,
db: AsyncSession = Depends(get_db),
):
"""Remove a tag from a photo. Removing a non-member is a no-op."""
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.tag_id == tag_id,
)
)
await db.commit()
return None
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(

View File

@@ -1,27 +1,114 @@
"""
Tags API router
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from pydantic import BaseModel
from sqlalchemy import select, func, insert, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Tag
from app.models.tags import photo_tags
router = APIRouter()
# ── Schemas ───────────────────────────────────────────────────────────────
class TagCreate(BaseModel):
name: str
color: Optional[str] = None
class TagUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)):
"""List all tags with usage counts"""
result = await db.execute(select(Tag))
tags = result.scalars().all()
return tags
"""List all tags with their photo counts."""
count_subq = (
select(
photo_tags.c.tag_id,
func.count(photo_tags.c.photo_id).label("photo_count"),
)
.group_by(photo_tags.c.tag_id)
.subquery()
)
stmt = (
select(Tag, count_subq.c.photo_count)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.order_by(Tag.name.asc())
)
result = await db.execute(stmt)
rows = result.all()
@router.post("")
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
"""Create a new tag"""
tag = Tag(name=name, color=color)
return [
{
"id": tag.id,
"name": tag.name,
"color": tag.color,
"photo_count": int(count or 0),
}
for tag, count in rows
]
@router.post("", status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"""Create a new tag. Names are unique — re-creating an existing name
returns the existing row instead of erroring (idempotent for the
autocomplete UI flow)."""
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute(select(Tag).where(Tag.name == name))
found = existing.scalar_one_or_none()
if found:
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0}
tag = Tag(name=name, color=body.color)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0}
@router.patch("/{tag_id}")
async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
):
"""Rename or recolor a tag."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
tag.name = name
if body.color is not None:
tag.color = body.color or None
await db.commit()
await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color}
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a tag. Photo associations cascade-delete via the FK."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
await db.delete(tag)
await db.commit()
return None

View File

@@ -1,7 +1,7 @@
import { X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { sourceFolders, heaps as heapsApi } from '../../services/api'
import { sourceFolders, heaps as heapsApi, tags as tagsApi } from '../../services/api'
export function ActiveFilterChips() {
const f = useFilterStore()
@@ -24,6 +24,12 @@ export function ActiveFilterChips() {
})
const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null
const { data: allTags = [] } = useQuery({
queryKey: ['tags'],
queryFn: tagsApi.list,
enabled: f.tagIds.length > 0,
})
if (!hasActiveFilters(f)) return null
const chips: { key: string; label: string; onRemove: () => void }[] = []
@@ -91,6 +97,14 @@ export function ActiveFilterChips() {
onRemove: () => f.setHeapId(null),
})
}
for (const tagId of f.tagIds) {
const tag = allTags.find((t) => t.id === tagId)
chips.push({
key: `tag-${tagId}`,
label: `Tag: ${tag?.name ?? tagId}`,
onRemove: () => f.toggleTagId(tagId),
})
}
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">

View File

@@ -7,6 +7,7 @@ import {
type FlagFilter,
type SortField,
} from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery'
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'photo', label: 'Photo' },
@@ -47,6 +48,9 @@ export function FilterBar() {
const flag = useFilterStore((s) => s.flag)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds)
const toggleTagId = useFilterStore((s) => s.toggleTagId)
const { data: allTags = [] } = useTagsQuery()
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
@@ -170,6 +174,29 @@ export function FilterBar() {
})}
</Group>
{/* Tags */}
{allTags.length > 0 && (
<Group label="Tags">
{allTags.map((tag) => {
const active = tagIds.includes(tag.id)
return (
<button
key={tag.id}
onClick={() => toggleTagId(tag.id)}
className={clsx(
'rounded px-2 py-1 transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{tag.name}
</button>
)
})}
</Group>
)}
{/* Sort */}
<Group label="Sort">
<select

View File

@@ -18,8 +18,16 @@ import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { tags as tagsApi, type Tag } from '../../services/api'
import { toast } from '../ToastContainer'
interface PhotoTagSummary {
id: string
name: string
color: string | null
}
interface PhotoDetails {
id: string
filename: string
@@ -34,6 +42,7 @@ interface PhotoDetails {
user_notes: string | null
color_label: string | null
exif_json: string | null
tags?: PhotoTagSummary[]
}
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
@@ -100,7 +109,7 @@ export function RightSidebar() {
const queryClient = useQueryClient()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['basic', 'camera', 'location'])
new Set(['basic', 'camera', 'location', 'tags'])
)
const toggleSection = (section: string) => {
@@ -174,6 +183,46 @@ export function RightSidebar() {
},
})
// ── Tags state + mutations ──────────────────────────────────────────
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
const invalidateTagsAndPhoto = () => {
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
}
const addTagMutation = useMutation({
mutationFn: async (name: string) => {
// Idempotent create — backend returns existing row if name matches.
const created = await tagsApi.create(name)
if (activePhotoId) {
await tagsApi.addToPhoto(activePhotoId, [created.id])
}
return created
},
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
})
const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) =>
tagsApi.addToPhoto(activePhotoId!, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) =>
tagsApi.removeFromPhoto(activePhotoId!, tagId),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Remove tag failed', e?.message || 'Unknown error'),
})
// Local drafts for the editable text fields. These mirror the server value
// but stay independent while the user is typing, so we don't fight focus or
// clobber edits with stale refetches.
@@ -520,6 +569,26 @@ export function RightSidebar() {
<div className="text-xs text-text-muted">No GPS data</div>
)}
</Section>
{/* Tags */}
<Section
title="Tags"
expanded={expandedSections.has('tags')}
onToggle={() => toggleSection('tags')}
>
<TagsEditor
photoTags={photo.tags ?? []}
allTags={allTags}
tagInput={tagInput}
onTagInputChange={setTagInput}
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
onCreateAndAttach={(name) => {
addTagMutation.mutate(name)
setTagInput('')
}}
onRemove={(id) => removeTagMutation.mutate(id)}
/>
</Section>
</>
)}
@@ -574,6 +643,129 @@ function Section({
)
}
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
function TagsEditor({
photoTags,
allTags,
tagInput,
onTagInputChange,
onAttachExisting,
onCreateAndAttach,
onRemove,
}: TagsEditorProps) {
const trimmed = tagInput.trim()
const lowerTrimmed = trimmed.toLowerCase()
const photoTagIds = new Set(photoTags.map((t) => t.id))
// Suggestions: tags whose name contains the input AND that aren't
// already on the photo. Capped at 6 to keep the dropdown short.
const suggestions = trimmed
? allTags
.filter(
(t) =>
!photoTagIds.has(t.id) &&
t.name.toLowerCase().includes(lowerTrimmed)
)
.slice(0, 6)
: []
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
: null
const handleSubmit = () => {
if (!trimmed) return
if (exactMatch) {
if (!photoTagIds.has(exactMatch.id)) {
onAttachExisting(exactMatch.id)
}
onTagInputChange('')
} else {
onCreateAndAttach(trimmed)
}
}
return (
<div className="space-y-2">
{/* Existing tag chips */}
{photoTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{photoTags.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={tag.color ? { backgroundColor: `${tag.color}33`, color: tag.color } : undefined}
>
{tag.name}
<button
onClick={() => onRemove(tag.id)}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
title="Remove tag"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags</div>
)}
{/* Add tag input + suggestions */}
<div className="relative">
<input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Add tag…"
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => {
onAttachExisting(s.id)
onTagInputChange('')
}}
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
>
{s.name}
</button>
))}
</div>
)}
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
>
+ Create "{trimmed}"
</button>
)}
</div>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>

View File

@@ -71,6 +71,12 @@ function parseUrl(): Partial<FilterState> {
const folderId = sp.get('folder_id')
if (folderId) out.folderId = folderId
const tagIds = sp.get('tag_ids')
if (tagIds) {
const ids = tagIds.split(',').map((t) => t.trim()).filter(Boolean)
if (ids.length > 0) out.tagIds = ids
}
const sortBy = sp.get('sort')
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
out.sortBy = sortBy as SortField
@@ -95,6 +101,7 @@ function writeUrl(f: FilterState) {
if (f.flag !== 'any') sp.set('flag', f.flag)
if (f.heapId) sp.set('heap_id', f.heapId)
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)

View File

@@ -20,6 +20,7 @@ export function usePhotosQuery() {
const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId)
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -35,10 +36,11 @@ export function usePhotosQuery() {
flag,
heapId,
folderId,
tagIds,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, sortBy, sortOrder]
)
return useQuery({

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { tags as tagsApi, type Tag } from '../services/api'
export const TAGS_QUERY_KEY = ['tags'] as const
export function useTagsQuery() {
return useQuery<Tag[]>({
queryKey: TAGS_QUERY_KEY,
queryFn: tagsApi.list,
staleTime: 30_000,
})
}

View File

@@ -191,32 +191,43 @@ export const heaps = {
}
// Tags API
export interface Tag {
id: string
name: string
color: string | null
photo_count: number
}
export const tags = {
list: async () => {
list: async (): Promise<Tag[]> => {
const response = await api.get('/tags')
return response.data
},
create: async (name: string, color?: string) => {
const response = await api.post('/tags', {
name,
color,
})
create: async (name: string, color?: string): Promise<Tag> => {
const response = await api.post('/tags', { name, color })
return response.data
},
update: async (tagId: string, data: {
name?: string
color?: string
}) => {
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
const response = await api.patch(`/tags/${tagId}`, data)
return response.data
},
delete: async (tagId: string) => {
const response = await api.delete(`/tags/${tagId}`)
delete: async (tagId: string): Promise<void> => {
await api.delete(`/tags/${tagId}`)
},
/** Add one or more tags to a photo. */
addToPhoto: async (photoId: string, tagIds: string[]) => {
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
return response.data
},
/** Remove a tag from a photo. */
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
await api.delete(`/photos/${photoId}/tags/${tagId}`)
},
}
// Discard API

View File

@@ -24,6 +24,8 @@ export interface FilterState {
heapId: string | null
/** When set, restrict to photos in this folder. */
folderId: string | null
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
tagIds: string[]
sortBy: SortField
sortOrder: SortOrder
}
@@ -40,6 +42,8 @@ interface FilterStore extends FilterState {
setFlag: (flag: FlagFilter) => void
setHeapId: (id: string | null) => void
setFolderId: (id: string | null) => void
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
@@ -61,6 +65,7 @@ export const INITIAL_FILTERS: FilterState = {
flag: 'any',
heapId: null,
folderId: null,
tagIds: [],
sortBy: 'taken_at',
sortOrder: 'desc',
}
@@ -83,6 +88,13 @@ export const useFilterStore = create<FilterStore>((set) => ({
setFlag: (flag) => set({ flag }),
setHeapId: (heapId) => set({ heapId }),
setFolderId: (folderId) => set({ folderId }),
setTagIds: (tagIds) => set({ tagIds }),
toggleTagId: (id) =>
set((s) => ({
tagIds: s.tagIds.includes(id)
? s.tagIds.filter((t) => t !== id)
: [...s.tagIds, id],
})),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>
@@ -108,6 +120,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.flag === 'discarded') params.is_discarded = 'true'
if (f.heapId) params.heap_id = f.heapId
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -124,6 +137,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.colorLabel !== null ||
f.flag !== 'any' ||
f.heapId !== null ||
f.folderId !== null
f.folderId !== null ||
f.tagIds.length > 0
)
}