Skip to content

Commit 4281d7f

Browse files
feat: ajouter une option de tri pour les commandes par date ou par nombre de copies
1 parent b47fbcf commit 4281d7f

4 files changed

Lines changed: 90 additions & 46 deletions

File tree

backend/api/routers/commands.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from typing import Literal
4+
35
from fastapi import APIRouter, Depends, HTTPException, Query, status
46
from sqlalchemy import func, or_, select
57
from sqlalchemy.orm import Session, selectinload
@@ -76,6 +78,7 @@ def list_commands_paged(
7678
is_favorite: bool | None = Query(default=None),
7779
q: str | None = Query(default=None),
7880
tag: list[str] | None = Query(default=None),
81+
sort: Literal["recent", "mostCopied"] = Query(default="recent"),
7982
limit: int = Query(default=20, ge=1, le=200),
8083
offset: int = Query(default=0, ge=0),
8184
db: Session = Depends(get_db),
@@ -107,10 +110,16 @@ def list_commands_paged(
107110
total_stmt = total_stmt.where(*where)
108111
total = int(db.scalar(total_stmt) or 0)
109112

113+
if sort == "mostCopied":
114+
order_by = (Command.copy_count.desc(), Command.updated_at.desc(), Command.id.desc())
115+
else:
116+
# recent
117+
order_by = (Command.updated_at.desc(), Command.id.desc())
118+
110119
stmt = (
111120
select(Command)
112121
.options(selectinload(Command.tag_entities))
113-
.order_by(Command.updated_at.desc())
122+
.order_by(*order_by)
114123
.limit(limit)
115124
.offset(offset)
116125
)

backend/api/routers/search.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import re
4+
from typing import Literal
45

56
from fastapi import APIRouter, Depends, Query
67
from sqlalchemy import and_, case, func, literal, or_, select
@@ -22,6 +23,7 @@ def search_commands(
2223
group_id: int | None = Query(default=None),
2324
is_favorite: bool | None = Query(default=None),
2425
tag: list[str] | None = Query(default=None),
26+
sort: Literal["recent", "mostCopied"] = Query(default="recent"),
2527
limit: int = Query(default=20, ge=1, le=200),
2628
db: Session = Depends(get_db),
2729
) -> SearchResponse:
@@ -79,11 +81,17 @@ def token_any_match(token: str):
7981
for match_expr in token_matches:
8082
score = score + case((match_expr, 1), else_=0)
8183

84+
if sort == "mostCopied":
85+
# Keep relevance first, then copies.
86+
order_by = (score.desc(), Command.copy_count.desc(), Command.updated_at.desc(), Command.id.desc())
87+
else:
88+
order_by = (score.desc(), Command.updated_at.desc(), Command.id.desc())
89+
8290
stmt = (
8391
select(Command, score.label("score"))
8492
.options(selectinload(Command.tag_entities))
8593
.where(where_clause)
86-
.order_by(score.desc(), Command.updated_at.desc())
94+
.order_by(*order_by)
8795
.limit(limit)
8896
)
8997

frontend/src/hooks/useCommandVault.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ type ApiCommandsPageResponse = {
7171
offset: number;
7272
};
7373

74+
type SortMode = "recent" | "mostCopied";
75+
7476
type ApiCommandStatsResponse = {
7577
total: number;
7678
favorites: number;
@@ -138,13 +140,20 @@ export function useCommandVault(token: string) {
138140

139141
const [stats, setStats] = useState<CommandStats>({ total: 0, favorites: 0, byGroup: {} });
140142

143+
const [tags, setTags] = useState<string[]>([]);
144+
141145
const [commandsLoading, setCommandsLoading] = useState(false);
142146
const [commandsLoadingMore, setCommandsLoadingMore] = useState(false);
143147
const [commandsHasMore, setCommandsHasMore] = useState(false);
144148
const [commandsTotal, setCommandsTotal] = useState(0);
145149
const [commandsOffset, setCommandsOffset] = useState(0);
146150
const commandsLimit = 30;
147-
const [commandsView, setCommandsView] = useState<{ groupId?: number; favoritesOnly?: boolean }>({});
151+
const [commandsView, setCommandsView] = useState<{ groupId?: number; favoritesOnly?: boolean; tags?: string[]; sort?: SortMode }>({ sort: "recent" });
152+
153+
const fetchTags = useCallback(async () => {
154+
const res = await apiRequest<string[]>("/tags", { token });
155+
setTags(Array.isArray(res) ? res : []);
156+
}, [token]);
148157

149158
const fetchStats = useCallback(async () => {
150159
const res = await apiRequest<ApiCommandStatsResponse>("/commands/stats", { token });
@@ -156,12 +165,14 @@ export function useCommandVault(token: string) {
156165
setStats({ total: res.total ?? 0, favorites: res.favorites ?? 0, byGroup });
157166
}, [token]);
158167

159-
const fetchCommandsPage = useCallback(async (opts: { groupId?: number; favoritesOnly?: boolean; limit: number; offset: number }) => {
168+
const fetchCommandsPage = useCallback(async (opts: { groupId?: number; favoritesOnly?: boolean; tags?: string[]; sort?: SortMode; limit: number; offset: number }) => {
160169
const res = await apiRequest<ApiCommandsPageResponse>("/commands/paged", {
161170
token,
162171
query: {
163172
group_id: opts.groupId,
164173
is_favorite: opts.favoritesOnly ? true : undefined,
174+
tag: opts.tags,
175+
sort: opts.sort,
165176
limit: opts.limit,
166177
offset: opts.offset,
167178
},
@@ -175,7 +186,7 @@ export function useCommandVault(token: string) {
175186
};
176187
}, [token]);
177188

178-
const loadInitialCommands = useCallback(async (view: { groupId?: number; favoritesOnly?: boolean }) => {
189+
const loadInitialCommands = useCallback(async (view: { groupId?: number; favoritesOnly?: boolean; tags?: string[]; sort?: SortMode }) => {
179190
setCommandsLoading(true);
180191
setCommandsView(view);
181192
try {
@@ -225,10 +236,11 @@ export function useCommandVault(token: string) {
225236
setData((prev) => ({ ...prev, groups: groups.map(mapGroup) }));
226237
await Promise.all([
227238
fetchStats(),
239+
fetchTags(),
228240
loadInitialCommands(commandsView),
229241
]);
230242
setLoading(false);
231-
}, [commandsView, fetchStats, loadInitialCommands, token]);
243+
}, [commandsView, fetchStats, fetchTags, loadInitialCommands, token]);
232244

233245
useEffect(() => {
234246
let cancelled = false;
@@ -242,6 +254,7 @@ export function useCommandVault(token: string) {
242254

243255
await Promise.all([
244256
fetchStats(),
257+
fetchTags(),
245258
loadInitialCommands({}),
246259
]);
247260

@@ -257,14 +270,14 @@ export function useCommandVault(token: string) {
257270
return () => {
258271
cancelled = true;
259272
};
260-
}, [fetchStats, loadInitialCommands, token]);
273+
}, [fetchStats, fetchTags, loadInitialCommands, token]);
261274

262275
const persist = useCallback((next: VaultData) => {
263276
// Backend is the source of truth; keep this for compatibility.
264277
setData(next);
265278
}, []);
266279

267-
const setCommandsViewAndReload = useCallback(async (view: { groupId?: number; favoritesOnly?: boolean }) => {
280+
const setCommandsViewAndReload = useCallback(async (view: { groupId?: number; favoritesOnly?: boolean; tags?: string[]; sort?: SortMode }) => {
268281
await loadInitialCommands(view);
269282
}, [loadInitialCommands]);
270283

@@ -286,7 +299,8 @@ export function useCommandVault(token: string) {
286299
groups: [...prev.groups, mapped].sort((a, b) => a.name.localeCompare(b.name)),
287300
}));
288301
await fetchStats();
289-
}, [fetchStats, token]);
302+
await fetchTags();
303+
}, [fetchStats, fetchTags, token]);
290304

291305
const updateGroup = useCallback(async (id: number, updates: Partial<Group>) => {
292306
const updated = await apiRequest<ApiGroup>(`/groups/${id}`, {
@@ -313,7 +327,8 @@ export function useCommandVault(token: string) {
313327
commands: prev.commands.filter((c) => c.groupId !== id),
314328
}));
315329
await fetchStats();
316-
}, [fetchStats, token]);
330+
await fetchTags();
331+
}, [fetchStats, fetchTags, token]);
317332

318333
// Commands
319334
const addCommand = useCallback(async (c: Omit<Command, "id" | "createdAt" | "updatedAt">) => {
@@ -339,7 +354,8 @@ export function useCommandVault(token: string) {
339354
return { ...prev, commands: [mapped, ...prev.commands] };
340355
});
341356
await fetchStats();
342-
}, [commandsView.favoritesOnly, commandsView.groupId, fetchStats, token]);
357+
await fetchTags();
358+
}, [commandsView.favoritesOnly, commandsView.groupId, fetchStats, fetchTags, token]);
343359

344360
const updateCommand = useCallback(async (id: number, updates: Partial<Command>) => {
345361
const updated = await apiRequest<ApiCommand>(`/commands/${id}`, {
@@ -376,13 +392,15 @@ export function useCommandVault(token: string) {
376392
return { ...prev, commands: prev.commands.map((c) => (c.id === id ? mapped : c)) };
377393
});
378394
await fetchStats();
379-
}, [commandsView.favoritesOnly, commandsView.groupId, fetchStats, token]);
395+
await fetchTags();
396+
}, [commandsView.favoritesOnly, commandsView.groupId, fetchStats, fetchTags, token]);
380397

381398
const deleteCommand = useCallback(async (id: number) => {
382399
await apiRequest(`/commands/${id}`, { method: "DELETE", token });
383400
setData((prev) => ({ ...prev, commands: prev.commands.filter((c) => c.id !== id) }));
384401
await fetchStats();
385-
}, [fetchStats, token]);
402+
await fetchTags();
403+
}, [fetchStats, fetchTags, token]);
386404

387405
const toggleFavorite = useCallback(async (id: number) => {
388406
const current = data.commands.find((c) => c.id === id);
@@ -462,7 +480,7 @@ export function useCommandVault(token: string) {
462480

463481
const searchCommands = useCallback(async (
464482
q: string,
465-
options?: { groupId?: number; limit?: number; tags?: string[]; isFavorite?: boolean; signal?: AbortSignal }
483+
options?: { groupId?: number; limit?: number; tags?: string[]; isFavorite?: boolean; sort?: SortMode; signal?: AbortSignal }
466484
) => {
467485
const res = await apiRequest<ApiSearchResponse>("/search", {
468486
token,
@@ -472,6 +490,7 @@ export function useCommandVault(token: string) {
472490
group_id: options?.groupId,
473491
is_favorite: options?.isFavorite,
474492
tag: options?.tags,
493+
sort: options?.sort,
475494
limit: options?.limit ?? 200,
476495
},
477496
});
@@ -494,6 +513,7 @@ export function useCommandVault(token: string) {
494513
commandsLoadingMore,
495514
commandsHasMore,
496515
commandsTotal,
516+
tags,
497517
stats,
498518
importing,
499519
error,

frontend/src/pages/Index.tsx

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
108108
const [tagsExpanded, setTagsExpanded] = useState(false);
109109
const [tagsOverflow, setTagsOverflow] = useState(false);
110110
const tagsContainerRef = useRef<HTMLDivElement | null>(null);
111+
const skipNextCommandsViewEffectRef = useRef(false);
112+
const hasInitializedCommandsViewRef = useRef(false);
111113
const loadMoreRef = useRef<HTMLDivElement | null>(null);
112114
const [layout, setLayout] = useState<"vertical" | "horizontal">(() => {
113115
if (typeof window === "undefined") return "vertical";
@@ -230,10 +232,10 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
230232
// This is used to keep the tag list stable when selecting multiple tags.
231233
const baseCommands = useMemo(() => {
232234
const isServerSearch = search.trim().length > 0;
233-
const cmds = isServerSearch ? [...(searchResults ?? [])] : [...vault.data.commands];
235+
if (isServerSearch) return searchResults ?? [];
234236
// vault.data.commands is already loaded for the current view.
235-
return cmds;
236-
}, [vault.data.commands, activeView, search, searchResults]);
237+
return vault.data.commands;
238+
}, [vault.data.commands, search, searchResults]);
237239

238240
const selectedCount = selectedIds.size;
239241
const selectedCommands = useMemo(() => {
@@ -252,27 +254,31 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
252254
});
253255
}, []);
254256

255-
// Filtered commands (applies tag filters + sorting)
256-
const filteredCommands = useMemo(() => {
257-
let cmds = [...baseCommands];
257+
const toggleTagFilter = useCallback((tag: string) => {
258+
setTagFilters((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
259+
}, []);
258260

259-
if (tagFilters.length > 0) {
260-
cmds = cmds.filter((c) => tagFilters.some((t) => c.tags.includes(t)));
261+
// Server-side paging view: reload when sort/tags change (but not while searching).
262+
useEffect(() => {
263+
if (!hasInitializedCommandsViewRef.current) {
264+
hasInitializedCommandsViewRef.current = true;
265+
return;
261266
}
262-
263-
if (sortMode === "mostCopied") {
264-
cmds.sort((a, b) => {
265-
if (b.copyCount !== a.copyCount) return b.copyCount - a.copyCount;
266-
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
267-
});
267+
if (skipNextCommandsViewEffectRef.current) {
268+
skipNextCommandsViewEffectRef.current = false;
269+
return;
268270
}
271+
if (activeView === "dashboard") return;
272+
if (search.trim().length > 0) return;
269273

270-
return cmds;
271-
}, [baseCommands, tagFilters, sortMode]);
272-
273-
const toggleTagFilter = useCallback((tag: string) => {
274-
setTagFilters((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
275-
}, []);
274+
void setCommandsView({
275+
...viewToOptions(activeView),
276+
tags: tagFilters,
277+
sort: sortMode,
278+
}).catch(() => {
279+
// ignore
280+
});
281+
}, [activeView, search, setCommandsView, sortMode, tagFilters.join("|"), viewToOptions]);
276282

277283
// Server-side search
278284
useEffect(() => {
@@ -310,14 +316,14 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
310316
}
311317

312318
const isFavorite = parsed.isFavorite ?? defaultFavorite;
313-
const tags = parsed.tags;
319+
const tags = Array.from(new Set([...(parsed.tags ?? []), ...(tagFilters ?? [])]));
314320

315321
setSearchLoading(true);
316322
setSearchResults(null);
317323

318324
const timeoutId = window.setTimeout(() => {
319325
void vault
320-
.searchCommands(parsed.text, { groupId, limit: 200, tags, isFavorite, signal: controller.signal })
326+
.searchCommands(parsed.text, { groupId, limit: 200, tags, isFavorite, sort: sortMode, signal: controller.signal })
321327
.then((items) => {
322328
if (cancelled) return;
323329
setSearchResults(items);
@@ -338,7 +344,7 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
338344
controller.abort();
339345
window.clearTimeout(timeoutId);
340346
};
341-
}, [search, activeView, vault.searchCommands, vault.data.groups]);
347+
}, [search, activeView, sortMode, tagFilters.join("|"), vault.searchCommands, vault.data.groups]);
342348

343349
// Current group
344350
const currentGroup = activeView.startsWith("group:")
@@ -403,12 +409,8 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
403409
return { groupId, favoritesOnly, tags, q };
404410
}, [activeView, search, tagFilters, viewToOptions, vault.data.groups]);
405411

406-
// All tags for filter (from baseCommands so tags don't disappear when filtering)
407-
const allTags = useMemo(() => {
408-
const tags = new Set<string>();
409-
baseCommands.forEach((c) => c.tags.forEach((t) => tags.add(t)));
410-
return [...tags].sort();
411-
}, [baseCommands]);
412+
// All tags for filter (global list, independent of paging)
413+
const allTags = useMemo(() => vault.tags ?? [], [vault.tags]);
412414

413415
useEffect(() => {
414416
const el = tagsContainerRef.current;
@@ -505,7 +507,12 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
505507
setSearchLoading(false);
506508
setTagFilters([]);
507509
setTagsExpanded(false);
508-
void setCommandsView(viewToOptions(v)).catch(() => {
510+
skipNextCommandsViewEffectRef.current = true;
511+
void setCommandsView({
512+
...viewToOptions(v),
513+
tags: [],
514+
sort: sortMode,
515+
}).catch(() => {
509516
// ignore
510517
});
511518
}}
@@ -814,9 +821,9 @@ function VaultPage({ token, onLogout }: { token: string; onLogout: () => void })
814821
</div>
815822
))}
816823
</div>
817-
) : filteredCommands.length > 0 ? (
824+
) : baseCommands.length > 0 ? (
818825
<div className={`grid gap-3 ${layout === "horizontal" ? "sm:grid-cols-2" : "grid-cols-1"}`}>
819-
{filteredCommands.map((cmd) => (
826+
{baseCommands.map((cmd) => (
820827
<CommandCard
821828
key={cmd.id}
822829
cmd={cmd}

0 commit comments

Comments
 (0)