generated from Dicken/dickendock
Commit 4: Kategorien-API (CRUD + Reorder) und Favoriten-Toggle im Frontend
This commit is contained in:
15
README.md
15
README.md
@@ -62,12 +62,16 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst:
|
|||||||
mit Repository-Layer über Drizzle (siehe `apps/backend/src/db/repositories`)
|
mit Repository-Layer über Drizzle (siehe `apps/backend/src/db/repositories`)
|
||||||
- ✅ Suche im Frontend gegen echte Backend-Daten (TanStack Query), inkl. Ranking-Logik
|
- ✅ Suche im Frontend gegen echte Backend-Daten (TanStack Query), inkl. Ranking-Logik
|
||||||
aus `packages/shared` und Tastatur-Navigation (Pfeiltasten, Enter, Escape)
|
aus `packages/shared` und Tastatur-Navigation (Pfeiltasten, Enter, Escape)
|
||||||
|
- ✅ Kategorien-API (`/api/categories`), inkl. Umbenennen, Löschen (Dienste behalten
|
||||||
|
ihre Zuordnung nicht, werden aber nicht gelöscht) und Bulk-Reorder für Drag & Drop
|
||||||
|
- ✅ Favoriten-Toggle direkt in der Trefferliste (Stern anklicken)
|
||||||
|
|
||||||
Noch **nicht** enthalten (folgt in den nächsten Commits):
|
Noch **nicht** enthalten (folgt in den nächsten Commits):
|
||||||
|
|
||||||
- Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung)
|
- Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung)
|
||||||
- Kategorien-Verwaltung & Favoriten-Toggle im UI
|
- Adminbereich mit Kategorien-Verwaltungsoberfläche (Erstellen, Umbenennen,
|
||||||
- Adminbereich, TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF
|
Drag & Drop) — Backend-API dafür existiert bereits
|
||||||
|
- TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF
|
||||||
|
|
||||||
Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge.
|
Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge.
|
||||||
|
|
||||||
@@ -86,6 +90,13 @@ GET /api/services/:id
|
|||||||
POST /api/services erfordert existierende deviceId
|
POST /api/services erfordert existierende deviceId
|
||||||
PATCH /api/services/:id
|
PATCH /api/services/:id
|
||||||
DELETE /api/services/:id
|
DELETE /api/services/:id
|
||||||
|
|
||||||
|
GET /api/categories
|
||||||
|
POST /api/categories
|
||||||
|
PATCH /api/categories/reorder Body: [{ id, order }, ...]
|
||||||
|
PATCH /api/categories/:id Umbenennen
|
||||||
|
DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null),
|
||||||
|
werden aber nicht gelöscht
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment auf dem Server (xlc-launchpad)
|
## Deployment auf dem Server (xlc-launchpad)
|
||||||
|
|||||||
101
apps/backend/src/db/repositories/categories.ts
Normal file
101
apps/backend/src/db/repositories/categories.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
CategoryCreateInput,
|
||||||
|
CategoryReorderInput,
|
||||||
|
CategoryUpdateInput,
|
||||||
|
} from "@launchpad/shared";
|
||||||
|
import { db } from "../client.js";
|
||||||
|
import { categories, services } from "../schema.js";
|
||||||
|
|
||||||
|
function nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(row: typeof categories.$inferSelect): Category {
|
||||||
|
return { id: row.id, name: row.name, order: row.order };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCategories(): Category[] {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.all()
|
||||||
|
.map(mapRow)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCategory(id: string): Category | null {
|
||||||
|
const row = db.select().from(categories).where(eq(categories.id, id)).get();
|
||||||
|
return row ? mapRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategory(input: CategoryCreateInput): Category {
|
||||||
|
const id = randomUUID();
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const existing = listCategories();
|
||||||
|
const nextOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.order)) + 1 : 0;
|
||||||
|
|
||||||
|
db.insert(categories)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
order: nextOrder,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getCategory(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
|
||||||
|
const existing = getCategory(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
db.update(categories)
|
||||||
|
.set({
|
||||||
|
...(input.name !== undefined && { name: input.name }),
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
})
|
||||||
|
.where(eq(categories.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return getCategory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Reihenfolge mehrerer Kategorien in einer Transaktion neu,
|
||||||
|
* z. B. nach Drag & Drop in der Admin-UI (siehe Commit 6).
|
||||||
|
*/
|
||||||
|
export function reorderCategories(input: CategoryReorderInput): Category[] {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const entry of input) {
|
||||||
|
tx.update(categories)
|
||||||
|
.set({ order: entry.order, updatedAt: nowIso() })
|
||||||
|
.where(eq(categories.id, entry.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return listCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht eine Kategorie. Dienste, die dieser Kategorie zugeordnet waren,
|
||||||
|
* verlieren ihre Kategoriezuordnung (category wird auf null gesetzt),
|
||||||
|
* werden aber nicht selbst gelöscht.
|
||||||
|
*/
|
||||||
|
export function deleteCategory(id: string): boolean {
|
||||||
|
const category = getCategory(id);
|
||||||
|
if (!category) return false;
|
||||||
|
|
||||||
|
db.update(services)
|
||||||
|
.set({ category: null, updatedAt: nowIso() })
|
||||||
|
.where(eq(services.category, category.name))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = db.delete(categories).where(eq(categories.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ensureSchema } from "./db/client.js";
|
|||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { serviceRoutes } from "./routes/services.js";
|
import { serviceRoutes } from "./routes/services.js";
|
||||||
|
import { categoryRoutes } from "./routes/categories.js";
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 3001);
|
const PORT = Number(process.env.PORT ?? 3001);
|
||||||
const HOST = process.env.HOST ?? "0.0.0.0";
|
const HOST = process.env.HOST ?? "0.0.0.0";
|
||||||
@@ -28,6 +29,7 @@ async function main() {
|
|||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(deviceRoutes);
|
await app.register(deviceRoutes);
|
||||||
await app.register(serviceRoutes);
|
await app.register(serviceRoutes);
|
||||||
|
await app.register(categoryRoutes);
|
||||||
|
|
||||||
app.get("/", async () => {
|
app.get("/", async () => {
|
||||||
return { name: "LaunchPad API", status: "running" };
|
return { name: "LaunchPad API", status: "running" };
|
||||||
|
|||||||
53
apps/backend/src/routes/categories.ts
Normal file
53
apps/backend/src/routes/categories.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategoryReorderSchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
} from "@launchpad/shared";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
|
|
||||||
|
export async function categoryRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/api/categories", async () => {
|
||||||
|
return categoryRepo.listCategories();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/categories", async (request, reply) => {
|
||||||
|
const parsed = CategoryCreateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const category = categoryRepo.createCategory(parsed.data);
|
||||||
|
return reply.code(201).send(category);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Muss vor der /:id-Route registriert sein, damit "reorder" nicht als ID interpretiert wird.
|
||||||
|
app.patch("/api/categories/reorder", async (request, reply) => {
|
||||||
|
const parsed = CategoryReorderSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return categoryRepo.reorderCategories(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/categories/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const parsed = CategoryUpdateSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
const category = categoryRepo.updateCategory(id, parsed.data);
|
||||||
|
if (!category) {
|
||||||
|
return reply.code(404).send({ error: "Kategorie nicht gefunden" });
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/categories/:id", async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = categoryRepo.deleteCategory(id);
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.code(404).send({ error: "Kategorie nicht gefunden" });
|
||||||
|
}
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
|
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
|
||||||
import { rankServices, type Service, type HealthStatus } from "@launchpad/shared";
|
import { rankServices, type Service, type HealthStatus } from "@launchpad/shared";
|
||||||
import { useServices } from "./hooks/useServices.js";
|
import { useServices } from "./hooks/useServices.js";
|
||||||
@@ -60,6 +61,18 @@ function openService(service: Service) {
|
|||||||
window.open(service.url, "_blank", "noopener,noreferrer");
|
window.open(service.url, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleServiceFavorite(service: Service): Promise<Service> {
|
||||||
|
const res = await fetch(`/api/services/${service.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ favorite: !service.favorite }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Favorit konnte nicht aktualisiert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [theme, toggleTheme] = useTheme();
|
const [theme, toggleTheme] = useTheme();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -67,6 +80,14 @@ export default function App() {
|
|||||||
const { health, error: healthError } = useBackendHealth();
|
const { health, error: healthError } = useBackendHealth();
|
||||||
const { data: services, isLoading, isError } = useServices();
|
const { data: services, isLoading, isError } = useServices();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const toggleFavorite = useMutation({
|
||||||
|
mutationFn: toggleServiceFavorite,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const results = useMemo(
|
const results = useMemo(
|
||||||
() => rankServices(services ?? [], query),
|
() => rankServices(services ?? [], query),
|
||||||
@@ -157,6 +178,7 @@ export default function App() {
|
|||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onHover={setSelectedIndex}
|
onHover={setSelectedIndex}
|
||||||
onOpen={openService}
|
onOpen={openService}
|
||||||
|
onToggleFavorite={(service) => toggleFavorite.mutate(service)}
|
||||||
emptyLabel={
|
emptyLabel={
|
||||||
(services?.length ?? 0) === 0
|
(services?.length ?? 0) === 0
|
||||||
? "Noch keine Dienste angelegt. Füge welche über die API hinzu."
|
? "Noch keine Dienste angelegt. Füge welche über die API hinzu."
|
||||||
|
|||||||
@@ -19,10 +19,17 @@ Grundgerüst aus Commit 1.
|
|||||||
> TanStack Router wird bewusst erst mit dem Adminbereich (Commit 6) eingeführt,
|
> TanStack Router wird bewusst erst mit dem Adminbereich (Commit 6) eingeführt,
|
||||||
> da es vorher keine zweite Route gibt, die er sinnvoll verwalten könnte.
|
> da es vorher keine zweite Route gibt, die er sinnvoll verwalten könnte.
|
||||||
|
|
||||||
## Commit 4 — Kategorien & Favoriten
|
## ✅ Commit 4 — Kategorien & Favoriten (erledigt)
|
||||||
|
|
||||||
- Kategorien: Erstellen, Löschen, Umbenennen, Drag & Drop-Sortierung
|
- Kategorien-API: Erstellen, Umbenennen, Löschen, Bulk-Reorder (`PATCH /api/categories/reorder`)
|
||||||
- Favoriten-Toggle, Favoriten erscheinen zuerst
|
- Löschen einer Kategorie setzt `category` bei betroffenen Diensten auf `null`,
|
||||||
|
löscht die Dienste aber nicht
|
||||||
|
- Favoriten-Toggle direkt in der Trefferliste des Frontends (Stern anklicken,
|
||||||
|
per `useMutation` + Cache-Invalidierung)
|
||||||
|
|
||||||
|
> Die eigentliche Kategorien-*Verwaltungsoberfläche* (Erstellen/Umbenennen/
|
||||||
|
> Drag & Drop in der UI) wandert in Commit 6 (Adminbereich) – die Startseite
|
||||||
|
> bleibt bewusst die minimalistische Suche, kein Verwaltungs-UI dort.
|
||||||
|
|
||||||
## Commit 5 — Scanner
|
## Commit 5 — Scanner
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export interface HealthStatus {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ranking-Stufen für die Suche, gemäß Spezifikation:
|
* Ranking-Stufen für die Suche, gemäß Spezifikation:
|
||||||
* 1. Displayname beginnt mit Suchtext
|
* 1. Displayname beginnt mit Suchtext
|
||||||
|
|||||||
@@ -46,3 +46,22 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({
|
|||||||
deviceId: true,
|
deviceId: true,
|
||||||
}).partial();
|
}).partial();
|
||||||
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
||||||
|
|
||||||
|
export const CategoryCreateSchema = z.object({
|
||||||
|
name: z.string().min(1, "name darf nicht leer sein"),
|
||||||
|
});
|
||||||
|
export type CategoryCreateInput = z.infer<typeof CategoryCreateSchema>;
|
||||||
|
|
||||||
|
export const CategoryUpdateSchema = CategoryCreateSchema.partial();
|
||||||
|
export type CategoryUpdateInput = z.infer<typeof CategoryUpdateSchema>;
|
||||||
|
|
||||||
|
/** Für Drag & Drop: neue Reihenfolge mehrerer Kategorien auf einmal setzen. */
|
||||||
|
export const CategoryReorderSchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
order: z.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, "mindestens ein Eintrag erforderlich");
|
||||||
|
export type CategoryReorderInput = z.infer<typeof CategoryReorderSchema>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { KeyboardEvent } from "react";
|
||||||
import type { Service } from "@launchpad/shared";
|
import type { Service } from "@launchpad/shared";
|
||||||
|
|
||||||
export interface ResultsListProps {
|
export interface ResultsListProps {
|
||||||
@@ -6,12 +7,13 @@ export interface ResultsListProps {
|
|||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onOpen: (service: Service) => void;
|
onOpen: (service: Service) => void;
|
||||||
|
onToggleFavorite?: (service: Service) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an.
|
* Zeigt die (bereits per rankServices sortierten) Suchtreffer an.
|
||||||
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
|
* Die Tastatur-Navigation (Pfeiltasten/Enter) wird vom Elternelement
|
||||||
* gesteuert; diese Komponente ist rein darstellend + klickbar.
|
* gesteuert; diese Komponente ist rein darstellend + klick-/tastaturbar.
|
||||||
*/
|
*/
|
||||||
export function ResultsList({
|
export function ResultsList({
|
||||||
services,
|
services,
|
||||||
@@ -19,6 +21,7 @@ export function ResultsList({
|
|||||||
emptyLabel = "Keine Dienste gefunden.",
|
emptyLabel = "Keine Dienste gefunden.",
|
||||||
onHover,
|
onHover,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onToggleFavorite,
|
||||||
}: ResultsListProps) {
|
}: ResultsListProps) {
|
||||||
if (services.length === 0) {
|
if (services.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -42,15 +45,19 @@ export function ResultsList({
|
|||||||
const active = index === selectedIndex;
|
const active = index === selectedIndex;
|
||||||
return (
|
return (
|
||||||
<li key={service.id} role="option" aria-selected={active}>
|
<li key={service.id} role="option" aria-selected={active}>
|
||||||
<button
|
<div
|
||||||
type="button"
|
tabIndex={-1}
|
||||||
onMouseEnter={() => onHover(index)}
|
onMouseEnter={() => onHover(index)}
|
||||||
onClick={() => onOpen(service)}
|
onClick={() => onOpen(service)}
|
||||||
className={`flex w-full items-center gap-3 px-5 py-3 text-left transition-colors ${
|
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
active
|
if (e.key === "Enter") onOpen(service);
|
||||||
? "bg-black/5 dark:bg-white/10"
|
}}
|
||||||
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
|
className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left
|
||||||
}`}
|
transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-black/5 dark:bg-white/10"
|
||||||
|
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{service.favicon ? (
|
{service.favicon ? (
|
||||||
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
|
<img src={service.favicon} alt="" className="h-5 w-5 shrink-0 rounded" />
|
||||||
@@ -79,12 +86,26 @@ export function ResultsList({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{service.favorite ? (
|
<button
|
||||||
<span className="shrink-0 text-amber-500" aria-label="Favorit">
|
type="button"
|
||||||
★
|
onClick={(e) => {
|
||||||
</span>
|
e.stopPropagation();
|
||||||
) : null}
|
onToggleFavorite?.(service);
|
||||||
</button>
|
}}
|
||||||
|
disabled={!onToggleFavorite}
|
||||||
|
aria-pressed={service.favorite}
|
||||||
|
aria-label={
|
||||||
|
service.favorite ? "Als Favorit entfernen" : "Als Favorit markieren"
|
||||||
|
}
|
||||||
|
className={`shrink-0 text-lg leading-none transition-colors ${
|
||||||
|
service.favorite
|
||||||
|
? "text-amber-500"
|
||||||
|
: "text-black/15 hover:text-amber-400 dark:text-white/15 dark:hover:text-amber-400"
|
||||||
|
} ${onToggleFavorite ? "" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user