generated from Dicken/dickendock
Reset-Button, Service-Reorder, Port/HTTPS-Spalten, Healthcheck in Sidebar, Kategorien-Sync
This commit is contained in:
@@ -131,13 +131,14 @@ DELETE /api/devices/:id (löscht zugehörige Dienste per Cascade)
|
|||||||
GET /api/services optional ?deviceId=&category=&favorite=true
|
GET /api/services optional ?deviceId=&category=&favorite=true
|
||||||
GET /api/services/:id
|
GET /api/services/:id
|
||||||
POST /api/services erfordert existierende deviceId
|
POST /api/services erfordert existierende deviceId
|
||||||
|
PATCH /api/services/reorder Body: [{ id, order }, ...]
|
||||||
PATCH /api/services/:id
|
PATCH /api/services/:id
|
||||||
DELETE /api/services/:id
|
DELETE /api/services/:id
|
||||||
|
|
||||||
GET /api/categories
|
GET /api/categories
|
||||||
POST /api/categories
|
POST /api/categories
|
||||||
PATCH /api/categories/reorder Body: [{ id, order }, ...]
|
PATCH /api/categories/reorder Body: [{ id, order }, ...]
|
||||||
PATCH /api/categories/:id Umbenennen
|
PATCH /api/categories/:id Umbenennen (aktualisiert automatisch alle Dienste mit altem Namen)
|
||||||
DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null),
|
DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null),
|
||||||
werden aber nicht gelöscht
|
werden aber nicht gelöscht
|
||||||
|
|
||||||
@@ -148,6 +149,9 @@ POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064
|
|||||||
|
|
||||||
GET /api/logs optional ?limit= (Default 100, Max 500)
|
GET /api/logs optional ?limit= (Default 100, Max 500)
|
||||||
|
|
||||||
|
POST /api/reset Löscht ALLE Geräte + Dienste (Cascade). Erfordert
|
||||||
|
Body { "confirm": true }, sonst 400.
|
||||||
|
|
||||||
GET /api/plugins geladene Plugins mit Capabilities
|
GET /api/plugins geladene Plugins mit Capabilities
|
||||||
POST /api/plugins/:name/import löst importDevices() eines Plugins aus
|
POST /api/plugins/:name/import löst importDevices() eines Plugins aus
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -50,14 +50,28 @@ export function createCategory(input: CategoryCreateInput): Category {
|
|||||||
return getCategory(id)!;
|
return getCategory(id)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benennt eine Kategorie um. Da Dienste ihre Kategorie als freier Text
|
||||||
|
* speichern (kein Fremdschlüssel), werden alle Dienste mit dem alten Namen
|
||||||
|
* automatisch auf den neuen Namen mit umgestellt, damit beides synchron bleibt.
|
||||||
|
*/
|
||||||
export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
|
export function updateCategory(id: string, input: CategoryUpdateInput): Category | null {
|
||||||
const existing = getCategory(id);
|
const existing = getCategory(id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
|
const timestamp = nowIso();
|
||||||
|
|
||||||
|
if (input.name !== undefined && input.name !== existing.name) {
|
||||||
|
db.update(services)
|
||||||
|
.set({ category: input.name, updatedAt: timestamp })
|
||||||
|
.where(eq(services.category, existing.name))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
db.update(categories)
|
db.update(categories)
|
||||||
.set({
|
.set({
|
||||||
...(input.name !== undefined && { name: input.name }),
|
...(input.name !== undefined && { name: input.name }),
|
||||||
updatedAt: nowIso(),
|
updatedAt: timestamp,
|
||||||
})
|
})
|
||||||
.where(eq(categories.id, id))
|
.where(eq(categories.id, id))
|
||||||
.run();
|
.run();
|
||||||
@@ -99,3 +113,41 @@ export function deleteCategory(id: string): boolean {
|
|||||||
const result = db.delete(categories).where(eq(categories.id, id)).run();
|
const result = db.delete(categories).where(eq(categories.id, id)).run();
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt eine Kategorie an, falls noch keine mit diesem Namen existiert
|
||||||
|
* (Vergleich ohne Groß-/Kleinschreibung). Wird beim Scannen aufgerufen,
|
||||||
|
* damit jede automatisch erkannte Kategorie auch im Adminbereich auftaucht.
|
||||||
|
*/
|
||||||
|
export function ensureCategory(name: string): Category {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const existing = listCategories().find(
|
||||||
|
(c) => c.name.toLowerCase() === trimmed.toLowerCase()
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
return createCategory({ name: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleicht eine Liste von Kategorienamen (z. B. alle distinct-Werte aus
|
||||||
|
* services.category) mit der categories-Tabelle ab und legt fehlende an.
|
||||||
|
* Wird beim Start aufgerufen, damit bereits vorhandene, aber nie über
|
||||||
|
* ensureCategory() angelegte Kategorien (z. B. aus Scans vor diesem Fix)
|
||||||
|
* im Adminbereich sichtbar werden. Gibt die Anzahl neu angelegter zurück.
|
||||||
|
*/
|
||||||
|
export function syncCategoriesFromServiceValues(categoryNames: string[]): number {
|
||||||
|
const existingNames = new Set(listCategories().map((c) => c.name.toLowerCase()));
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const rawName of categoryNames) {
|
||||||
|
const trimmed = rawName.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (existingNames.has(trimmed.toLowerCase())) continue;
|
||||||
|
|
||||||
|
createCategory({ name: trimmed });
|
||||||
|
existingNames.add(trimmed.toLowerCase());
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ export function deleteDevice(id: string): boolean {
|
|||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Löscht ALLE Geräte (und per FK-Cascade alle zugehörigen Dienste). Für den Reset-Button im Adminbereich. */
|
||||||
|
export function deleteAllDevices(): number {
|
||||||
|
const result = db.delete(devices).run();
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceScanInput {
|
export interface DeviceScanInput {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
|||||||
@@ -124,6 +124,23 @@ export function deleteService(id: string): boolean {
|
|||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Reihenfolge mehrerer Dienste in einer Transaktion neu
|
||||||
|
* (z. B. nach Drag & Drop in der Admin-UI).
|
||||||
|
*/
|
||||||
|
export function reorderServices(input: { id: string; order: number }[]): Service[] {
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const entry of input) {
|
||||||
|
tx.update(services)
|
||||||
|
.set({ order: entry.order, updatedAt: nowIso() })
|
||||||
|
.where(eq(services.id, entry.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return listServices();
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServiceScanInput {
|
export interface ServiceScanInput {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { categoryRoutes } from "./routes/categories.js";
|
|||||||
import { scanRoutes } from "./routes/scan.js";
|
import { scanRoutes } from "./routes/scan.js";
|
||||||
import { logRoutes } from "./routes/logs.js";
|
import { logRoutes } from "./routes/logs.js";
|
||||||
import { pluginRoutes } from "./routes/plugins.js";
|
import { pluginRoutes } from "./routes/plugins.js";
|
||||||
|
import { resetRoutes } from "./routes/reset.js";
|
||||||
import { loadPlugins } from "./plugins/loader.js";
|
import { loadPlugins } from "./plugins/loader.js";
|
||||||
|
import * as serviceRepo from "./db/repositories/services.js";
|
||||||
|
import * as categoryRepo from "./db/repositories/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";
|
||||||
@@ -31,6 +34,23 @@ async function main() {
|
|||||||
|
|
||||||
ensureSchema();
|
ensureSchema();
|
||||||
|
|
||||||
|
// Kategorien, die bereits auf Diensten stehen (z. B. aus Scans vor diesem
|
||||||
|
// Fix, oder von Plugins), aber noch nicht in der categories-Tabelle sind,
|
||||||
|
// hier nachtragen. Damit zeigt Admin -> Kategorien immer das, was auch in
|
||||||
|
// der Suche als Kategorie auftaucht.
|
||||||
|
const existingCategoryNames = Array.from(
|
||||||
|
new Set(
|
||||||
|
serviceRepo
|
||||||
|
.listServices()
|
||||||
|
.map((s) => s.category)
|
||||||
|
.filter((c): c is string => !!c)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const syncedCount = categoryRepo.syncCategoriesFromServiceValues(existingCategoryNames);
|
||||||
|
if (syncedCount > 0) {
|
||||||
|
app.log.info(`${syncedCount} Kategorie(n) aus bestehenden Diensten nachgetragen`);
|
||||||
|
}
|
||||||
|
|
||||||
const plugins = await loadPlugins();
|
const plugins = await loadPlugins();
|
||||||
app.log.info(`${plugins.length} Plugin(s) geladen`);
|
app.log.info(`${plugins.length} Plugin(s) geladen`);
|
||||||
|
|
||||||
@@ -41,6 +61,7 @@ async function main() {
|
|||||||
await app.register(scanRoutes);
|
await app.register(scanRoutes);
|
||||||
await app.register(logRoutes);
|
await app.register(logRoutes);
|
||||||
await app.register(pluginRoutes);
|
await app.register(pluginRoutes);
|
||||||
|
await app.register(resetRoutes);
|
||||||
|
|
||||||
app.get("/", async () => {
|
app.get("/", async () => {
|
||||||
return { name: "LaunchPad API", status: "running" };
|
return { name: "LaunchPad API", status: "running" };
|
||||||
|
|||||||
34
apps/backend/src/routes/reset.ts
Normal file
34
apps/backend/src/routes/reset.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht ALLE Geräte und (per FK-Cascade) alle zugehörigen Dienste.
|
||||||
|
* Kategorien und Logs bleiben unangetastet.
|
||||||
|
*
|
||||||
|
* Erfordert { confirm: true } im Body – zusätzlich zur Bestätigung im
|
||||||
|
* Frontend eine zweite Sicherung gegen versehentliche Aufrufe (z. B. per
|
||||||
|
* Skript oder Browser-Erweiterung).
|
||||||
|
*/
|
||||||
|
export async function resetRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post("/api/reset", async (request, reply) => {
|
||||||
|
const body = request.body as { confirm?: boolean } | undefined;
|
||||||
|
|
||||||
|
if (body?.confirm !== true) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "Bestätigung erforderlich. Bitte { \"confirm\": true } im Body senden.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceCount = deviceRepo.listDevices().length;
|
||||||
|
const deletedCount = deviceRepo.deleteAllDevices();
|
||||||
|
|
||||||
|
logRepo.logScan({
|
||||||
|
type: "device",
|
||||||
|
level: "info",
|
||||||
|
message: `Reset: ${deletedCount} Gerät(e) und alle zugehörigen Dienste gelöscht`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deletedDevices: deletedCount, previousDeviceCount: deviceCount };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import * as deviceRepo from "../db/repositories/devices.js";
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
import * as serviceRepo from "../db/repositories/services.js";
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
import * as categoryRepo from "../db/repositories/categories.js";
|
||||||
import * as logRepo from "../db/repositories/logs.js";
|
import * as logRepo from "../db/repositories/logs.js";
|
||||||
import { scanDeviceServices } from "../scanner/networkScanner.js";
|
import { scanDeviceServices } from "../scanner/networkScanner.js";
|
||||||
import { fetchFritzBoxHosts } from "../scanner/fritzbox.js";
|
import { fetchFritzBoxHosts } from "../scanner/fritzbox.js";
|
||||||
@@ -22,6 +23,13 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const discovered = await scanDeviceServices(device);
|
const discovered = await scanDeviceServices(device);
|
||||||
|
|
||||||
|
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
|
||||||
|
// sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert
|
||||||
|
// werden kann (services.category ist reiner Freitext, kein Fremdschlüssel).
|
||||||
|
for (const category of new Set(discovered.map((d) => d.category).filter((c): c is string => !!c))) {
|
||||||
|
categoryRepo.ensureCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
const results = discovered.map((found) =>
|
const results = discovered.map((found) =>
|
||||||
serviceRepo.upsertServiceFromScan({
|
serviceRepo.upsertServiceFromScan({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { ServiceCreateSchema, ServiceUpdateSchema } from "@launchpad/shared";
|
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
|
||||||
import * as deviceRepo from "../db/repositories/devices.js";
|
import * as deviceRepo from "../db/repositories/devices.js";
|
||||||
import * as serviceRepo from "../db/repositories/services.js";
|
import * as serviceRepo from "../db/repositories/services.js";
|
||||||
|
|
||||||
@@ -43,6 +43,15 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(201).send(service);
|
return reply.code(201).send(service);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
|
||||||
|
app.patch("/api/services/reorder", async (request, reply) => {
|
||||||
|
const parsed = ServiceReorderSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
|
||||||
|
}
|
||||||
|
return serviceRepo.reorderServices(parsed.data);
|
||||||
|
});
|
||||||
|
|
||||||
app.patch("/api/services/:id", async (request, reply) => {
|
app.patch("/api/services/:id", async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const parsed = ServiceUpdateSchema.safeParse(request.body);
|
const parsed = ServiceUpdateSchema.safeParse(request.body);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { SearchInput, StatusBadge, ResultsList } from "@launchpad/ui";
|
import { SearchInput, StatusBadge, ResultsList, FavoritesBar } from "@launchpad/ui";
|
||||||
import { rankServices, type Service } from "@launchpad/shared";
|
import { rankServices, type Service } from "@launchpad/shared";
|
||||||
import { useServices } from "../hooks/useServices.js";
|
import { useServices } from "../hooks/useServices.js";
|
||||||
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
||||||
@@ -52,6 +52,14 @@ export function HomePage() {
|
|||||||
[visibleServices, query]
|
[visibleServices, query]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const favoriteServices = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleServices
|
||||||
|
.filter((s) => s.favorite)
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
[visibleServices]
|
||||||
|
);
|
||||||
|
|
||||||
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
|
// Auswahl zurücksetzen, sobald sich die Trefferliste ändert
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
@@ -124,6 +132,12 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-xl">
|
<div className="w-full max-w-xl">
|
||||||
|
{favoriteServices.length > 0 ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<FavoritesBar services={favoriteServices} onOpen={openService} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SearchInput
|
<SearchInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { StatusBadge } from "@launchpad/ui";
|
||||||
|
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" },
|
{ to: "/admin/dashboard", label: "Dashboard", icon: "📊" },
|
||||||
@@ -15,6 +17,8 @@ const NAV_ITEMS = [
|
|||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const { health, error: healthError } = useBackendHealth();
|
||||||
|
const isOnline = !healthError && health?.status === "ok";
|
||||||
|
|
||||||
// Drawer schließen, wenn per Navigation die Seite wechselt
|
// Drawer schließen, wenn per Navigation die Seite wechselt
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,6 +102,12 @@ export function AdminLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
|
<div className="shrink-0 border-t border-black/10 p-3 dark:border-white/10">
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<StatusBadge
|
||||||
|
online={isOnline}
|
||||||
|
label={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, type DragEvent } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@launchpad/ui";
|
import { Button } from "@launchpad/ui";
|
||||||
import type { Service } from "@launchpad/shared";
|
import type { Service } from "@launchpad/shared";
|
||||||
@@ -37,6 +37,20 @@ async function deleteServiceRequest(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reorderServicesRequest(entries: { id: string; order: number }[]) {
|
||||||
|
const res = await fetch("/api/services/reorder", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(entries),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDIT_FORM_COLSPAN = 9;
|
||||||
|
|
||||||
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
|
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [displayName, setDisplayName] = useState(service.displayName);
|
const [displayName, setDisplayName] = useState(service.displayName);
|
||||||
@@ -73,7 +87,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||||||
@@ -169,7 +183,19 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceRow({ service }: { service: Service }) {
|
function ServiceRow({
|
||||||
|
service,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
isDragging,
|
||||||
|
}: {
|
||||||
|
service: Service;
|
||||||
|
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
@@ -194,11 +220,20 @@ function ServiceRow({ service }: { service: Service }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
draggable
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
|
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
|
||||||
service.visible ? "" : "opacity-50"
|
service.visible ? "" : "opacity-50"
|
||||||
}`}
|
} ${isDragging ? "opacity-40" : ""}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-2 py-3 text-center">
|
||||||
|
<span className="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
|
||||||
|
⠿⠿
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => favoriteMutation.mutate()}
|
onClick={() => favoriteMutation.mutate()}
|
||||||
@@ -226,14 +261,24 @@ function ServiceRow({ service }: { service: Service }) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-black/40 dark:text-white/40">
|
<div className="text-xs text-black/40 dark:text-white/40">{service.hostname}</div>
|
||||||
{service.hostname}:{service.port}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? "–"}</td>
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? "–"}</td>
|
||||||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||||||
{service.alias.length > 0 ? service.alias.join(", ") : "–"}
|
{service.alias.length > 0 ? service.alias.join(", ") : "–"}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{service.port}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
service.https
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{service.https ? "https" : "http"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<a
|
<a
|
||||||
href={service.url}
|
href={service.url}
|
||||||
@@ -241,7 +286,7 @@ function ServiceRow({ service }: { service: Service }) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
öffnen{service.https ? " (https)" : ""}
|
öffnen
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -260,16 +305,60 @@ function ServiceRow({ service }: { service: Service }) {
|
|||||||
|
|
||||||
export function ServicesPage() {
|
export function ServicesPage() {
|
||||||
const { data: services, isLoading, isError } = useServices();
|
const { data: services, isLoading, isError } = useServices();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
|
||||||
|
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: reorderServicesRequest,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
setLocalOrder(null);
|
||||||
|
},
|
||||||
|
onError: () => setLocalOrder(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = localOrder ?? services ?? [];
|
||||||
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
|
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
|
||||||
|
|
||||||
|
function handleDragStart(id: string) {
|
||||||
|
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(targetId: string) {
|
||||||
|
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draggedId || draggedId === targetId) return;
|
||||||
|
|
||||||
|
const current = localOrder ?? services ?? [];
|
||||||
|
const fromIndex = current.findIndex((s) => s.id === draggedId);
|
||||||
|
const toIndex = current.findIndex((s) => s.id === targetId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
|
||||||
|
const next = [...current];
|
||||||
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
|
next.splice(toIndex, 0, moved);
|
||||||
|
setLocalOrder(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop() {
|
||||||
|
return (e: DragEvent<HTMLTableRowElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDraggedId(null);
|
||||||
|
const current = localOrder ?? services ?? [];
|
||||||
|
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title="Dienste"
|
title="Dienste"
|
||||||
description={
|
description={
|
||||||
hiddenCount > 0
|
hiddenCount > 0
|
||||||
? `Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
? `Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten. ${hiddenCount} Dienst(e) sind aktuell in der Suche ausgeblendet (🙈).`
|
||||||
: "Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
: "Per Drag & Drop sortierbar (⠿⠿). Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -277,24 +366,34 @@ export function ServicesPage() {
|
|||||||
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste …</p>
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste …</p>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
|
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
|
||||||
) : services && services.length > 0 ? (
|
) : list.length > 0 ? (
|
||||||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[720px] text-sm">
|
<table className="w-full min-w-[860px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||||
<th className="px-4 py-2 font-medium" />
|
<th className="px-2 py-2" />
|
||||||
|
<th className="px-2 py-2" />
|
||||||
<th className="px-4 py-2 font-medium">Dienst</th>
|
<th className="px-4 py-2 font-medium">Dienst</th>
|
||||||
<th className="px-4 py-2 font-medium">Kategorie</th>
|
<th className="px-4 py-2 font-medium">Kategorie</th>
|
||||||
<th className="px-4 py-2 font-medium">Alias</th>
|
<th className="px-4 py-2 font-medium">Alias</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Port</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Protokoll</th>
|
||||||
<th className="px-4 py-2 font-medium">URL</th>
|
<th className="px-4 py-2 font-medium">URL</th>
|
||||||
<th className="px-4 py-2" />
|
<th className="px-4 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{services.map((service) => (
|
{list.map((service) => (
|
||||||
<ServiceRow key={service.id} service={service} />
|
<ServiceRow
|
||||||
|
key={service.id}
|
||||||
|
service={service}
|
||||||
|
isDragging={draggedId === service.id}
|
||||||
|
onDragStart={handleDragStart(service.id)}
|
||||||
|
onDragOver={handleDragOver(service.id)}
|
||||||
|
onDrop={handleDrop()}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@launchpad/ui";
|
||||||
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
|
||||||
import { useTheme } from "../../hooks/useTheme.js";
|
import { useTheme } from "../../hooks/useTheme.js";
|
||||||
import { AdminPageHeader } from "./AdminPageHeader.js";
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
||||||
@@ -11,6 +14,66 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetEverything() {
|
||||||
|
const res = await fetch("/api/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ confirm: true }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.error ?? `Reset fehlgeschlagen (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
return body as { deletedDevices: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function DangerZone() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: resetEverything,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setMessage(`${result.deletedDevices} Gerät(e) und alle zugehörigen Dienste gelöscht.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => setMessage(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
const firstConfirm = window.confirm(
|
||||||
|
"Wirklich ALLE Geräte und Dienste unwiderruflich löschen? Kategorien bleiben erhalten."
|
||||||
|
);
|
||||||
|
if (!firstConfirm) return;
|
||||||
|
|
||||||
|
const secondConfirm = window.confirm(
|
||||||
|
"Ganz sicher? Das kann nicht rückgängig gemacht werden."
|
||||||
|
);
|
||||||
|
if (!secondConfirm) return;
|
||||||
|
|
||||||
|
setMessage(null);
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-red-500/20 p-5">
|
||||||
|
<h2 className="mb-1 font-medium text-red-600 dark:text-red-400">Gefahrenzone</h2>
|
||||||
|
<p className="mb-4 text-sm text-black/50 dark:text-white/50">
|
||||||
|
Löscht alle Geräte und alle zugehörigen Dienste unwiderruflich. Kategorien und Logs
|
||||||
|
bleiben erhalten.
|
||||||
|
</p>
|
||||||
|
<Button variant="danger" onClick={handleClick} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Lösche …" : "Alle Geräte & Dienste löschen"}
|
||||||
|
</Button>
|
||||||
|
{message ? (
|
||||||
|
<p className="mt-3 text-sm text-black/60 dark:text-white/60">{message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { health, error } = useBackendHealth();
|
const { health, error } = useBackendHealth();
|
||||||
const [theme, toggleTheme] = useTheme();
|
const [theme, toggleTheme] = useTheme();
|
||||||
@@ -49,6 +112,8 @@ export function SettingsPage() {
|
|||||||
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
<p className="text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DangerZone />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -154,3 +154,30 @@ Geräte):
|
|||||||
`hostname`), in der Suche wie im Adminbereich.
|
`hostname`), in der Suche wie im Adminbereich.
|
||||||
- **Admin-Sidebar ist jetzt echt fixiert** (`fixed` statt `static`) – vorher
|
- **Admin-Sidebar ist jetzt echt fixiert** (`fixed` statt `static`) – vorher
|
||||||
scrollte "Zurück zur Suche" bei langem Seiteninhalt aus dem Sichtfeld.
|
scrollte "Zurück zur Suche" bei langem Seiteninhalt aus dem Sichtfeld.
|
||||||
|
|
||||||
|
## Weitere Fixes (zweite Testrunde)
|
||||||
|
|
||||||
|
- **Reset-Button** unter Admin -> Einstellungen -> Gefahrenzone: löscht alle
|
||||||
|
Geräte und (per Cascade) alle Dienste unwiderruflich. Doppelte Bestätigung
|
||||||
|
im Frontend (`window.confirm`) plus eine Pflicht-Bestätigung auf API-Ebene
|
||||||
|
(`POST /api/reset` verlangt `{"confirm": true}`, sonst `400`). Kategorien
|
||||||
|
und Logs bleiben erhalten.
|
||||||
|
- **Port und Protokoll (http/https) als eigene Spalten** in der
|
||||||
|
Dienste-Tabelle im Adminbereich, nicht mehr nur implizit in der URL.
|
||||||
|
- **Favoriten-Leiste über der Suchleiste** auf der Startseite – immer
|
||||||
|
sichtbar, unabhängig vom Suchfeld, sortiert nach der einstellbaren
|
||||||
|
Reihenfolge.
|
||||||
|
- **Dienste per Drag & Drop sortierbar** im Adminbereich (`PATCH
|
||||||
|
/api/services/reorder`), bestimmt sowohl die Reihenfolge in der
|
||||||
|
Favoriten-Leiste als auch bei gleichrangigen Suchtreffern.
|
||||||
|
- **Healthcheck durchgängig in der Admin-Sidebar sichtbar** (vorher nur auf
|
||||||
|
der Startseite und versteckt unter Einstellungen).
|
||||||
|
- **Kategorien-Synchronisierung:** Die `categories`-Tabelle war leer, obwohl
|
||||||
|
Dienste bereits Kategorien-Text trugen (z. B. aus der automatischen
|
||||||
|
Softwareerkennung) – `services.category` ist reiner Freitext, kein
|
||||||
|
Fremdschlüssel. Jetzt: (1) beim Start werden alle bereits auf Diensten
|
||||||
|
vorhandenen Kategorienamen automatisch in die `categories`-Tabelle
|
||||||
|
nachgetragen, (2) jeder Scan legt neu erkannte Kategorien automatisch an,
|
||||||
|
(3) Umbenennen einer Kategorie im Adminbereich aktualisiert automatisch
|
||||||
|
alle Dienste mit dem alten Namen. Einschränkung: manuell per API angelegte
|
||||||
|
Dienste (nicht über einen Scan) lösen diesen Sync aktuell nicht aus.
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ export const ServiceUpdateSchema = ServiceCreateSchema.omit({
|
|||||||
}).partial();
|
}).partial();
|
||||||
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
export type ServiceUpdateInput = z.infer<typeof ServiceUpdateSchema>;
|
||||||
|
|
||||||
|
/** Für Drag & Drop: neue Reihenfolge mehrerer Dienste auf einmal setzen. */
|
||||||
|
export const ServiceReorderSchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
order: z.number(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, "mindestens ein Eintrag erforderlich");
|
||||||
|
export type ServiceReorderInput = z.infer<typeof ServiceReorderSchema>;
|
||||||
|
|
||||||
export const CategoryCreateSchema = z.object({
|
export const CategoryCreateSchema = z.object({
|
||||||
name: z.string().min(1, "name darf nicht leer sein"),
|
name: z.string().min(1, "name darf nicht leer sein"),
|
||||||
});
|
});
|
||||||
|
|||||||
47
packages/ui/src/FavoritesBar.tsx
Normal file
47
packages/ui/src/FavoritesBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Service } from "@launchpad/shared";
|
||||||
|
|
||||||
|
export interface FavoritesBarProps {
|
||||||
|
services: Service[];
|
||||||
|
onOpen: (service: Service) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt Favoriten als anklickbare Chips – immer sichtbar, unabhängig vom
|
||||||
|
* Suchfeld. Reihenfolge folgt service.order (im Adminbereich per Drag & Drop
|
||||||
|
* änderbar).
|
||||||
|
*/
|
||||||
|
export function FavoritesBar({ services, onOpen }: FavoritesBarProps) {
|
||||||
|
if (services.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Favoriten"
|
||||||
|
className="flex flex-wrap items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{services.map((service) => (
|
||||||
|
<button
|
||||||
|
key={service.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(service)}
|
||||||
|
title={`${service.displayName} (${service.hostname}:${service.port})`}
|
||||||
|
className="flex items-center gap-2 rounded-full border border-black/10 bg-white/70
|
||||||
|
px-3 py-1.5 text-sm text-black transition-colors hover:bg-black/5
|
||||||
|
dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{service.favicon ? (
|
||||||
|
<img src={service.favicon} alt="" className="h-4 w-4 shrink-0 rounded" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full
|
||||||
|
bg-black/10 text-[9px] font-medium text-black/50 dark:bg-white/10 dark:text-white/50"
|
||||||
|
>
|
||||||
|
{service.displayName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[10rem] truncate">{service.displayName}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,3 +9,6 @@ export type { ResultsListProps } from "./ResultsList.js";
|
|||||||
|
|
||||||
export { Button } from "./Button.js";
|
export { Button } from "./Button.js";
|
||||||
export type { ButtonProps } from "./Button.js";
|
export type { ButtonProps } from "./Button.js";
|
||||||
|
|
||||||
|
export { FavoritesBar } from "./FavoritesBar.js";
|
||||||
|
export type { FavoritesBarProps } from "./FavoritesBar.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user