diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index edd749b..94817a0 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -85,6 +85,7 @@ jobs: - name: Generate latest.json run: | TAG="${GITHUB_REF_NAME}" + VERSION="${GITHUB_REF_NAME#v}" PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}" @@ -121,7 +122,7 @@ jobs: fi jq -n \ - --arg version "$TAG" \ + --arg version "$VERSION" \ --arg notes "${{ steps.changelog.outputs.notes }}" \ --arg pub_date "$PUB_DATE" \ --argjson platforms "$PLATFORMS" \ @@ -198,6 +199,16 @@ jobs: FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | API_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest" + # Always delete the old version first to avoid 409 conflicts + echo "Deleting old package version (if any)..." + curl -s -X DELETE \ + "${API_URL}/latest.json" \ + -H "Authorization: token ${FORGEJO_TOKEN}" || true + # Delete the package version itself to allow re-upload + DELETE_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest" + curl -s -X DELETE \ + "${DELETE_URL}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" || true echo "Uploading latest.json to package registry..." HTTP_CODE=$(curl -w "%{http_code}" -X PUT \ "${API_URL}/latest.json" \ @@ -206,20 +217,7 @@ jobs: --data-binary "@release-assets/latest.json" \ -o /tmp/pkg_response.json) echo "HTTP $HTTP_CODE" - if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "409" ]; then - echo "Upload failed:" + if [ "$HTTP_CODE" != "201" ]; then + echo "Upload response:" cat /tmp/pkg_response.json fi - if [ "$HTTP_CODE" = "409" ]; then - echo "Package version exists, deleting and re-uploading..." - curl -s -X DELETE \ - "${API_URL}/latest.json" \ - -H "Authorization: token ${FORGEJO_TOKEN}" - HTTP_CODE=$(curl -w "%{http_code}" -X PUT \ - "${API_URL}/latest.json" \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -H "Content-Type: application/json" \ - --data-binary "@release-assets/latest.json" \ - -o /tmp/pkg_response.json) - echo "Re-upload HTTP $HTTP_CODE" - fi diff --git a/src/components/settings/LogViewerCard.tsx b/src/components/settings/LogViewerCard.tsx new file mode 100644 index 0000000..2972d2d --- /dev/null +++ b/src/components/settings/LogViewerCard.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useRef, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollText, Trash2, Copy, Check } from "lucide-react"; +import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService"; + +type Filter = "all" | LogLevel; + +export default function LogViewerCard() { + const { t } = useTranslation(); + const [filter, setFilter] = useState("all"); + const [copied, setCopied] = useState(false); + const listRef = useRef(null); + + const logs = useSyncExternalStore(subscribe, getLogs, getLogs); + + const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [filtered.length]); + + const handleCopy = async () => { + const text = filtered + .map((l) => { + const time = new Date(l.timestamp).toLocaleTimeString(); + return `[${time}] [${l.level.toUpperCase()}] ${l.message}`; + }) + .join("\n"); + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const levelColor: Record = { + info: "text-[var(--muted-foreground)]", + warn: "text-amber-500", + error: "text-[var(--negative)]", + }; + + const filters: { value: Filter; label: string }[] = [ + { value: "all", label: t("settings.logs.filterAll") }, + { value: "error", label: "Error" }, + { value: "warn", label: "Warn" }, + { value: "info", label: "Info" }, + ]; + + return ( +
+
+

+ + {t("settings.logs.title")} +

+
+ + +
+
+ +
+ {filters.map((f) => ( + + ))} +
+ +
+ {filtered.length === 0 ? ( +

+ {t("settings.logs.empty")} +

+ ) : ( + filtered.map((entry, i) => ( +
+ + {new Date(entry.timestamp).toLocaleTimeString()} + + + {entry.level} + + {entry.message} +
+ )) + )} +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 35fef04..ee6e3c0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -467,6 +467,14 @@ "title": "User Guide", "description": "Learn how to use all features of the app" }, + "logs": { + "title": "Logs", + "clear": "Clear", + "copy": "Copy", + "copied": "Copied!", + "empty": "No logs", + "filterAll": "All" + }, "dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.", "help": { "title": "About Settings", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 105f315..92ffe86 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -467,6 +467,14 @@ "title": "Guide d'utilisation", "description": "Apprenez à utiliser toutes les fonctionnalités de l'application" }, + "logs": { + "title": "Journaux", + "clear": "Effacer", + "copy": "Copier", + "copied": "Copié !", + "empty": "Aucun journal", + "filterAll": "Tout" + }, "dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.", "help": { "title": "À propos des Paramètres", diff --git a/src/main.tsx b/src/main.tsx index cd4ea6c..f1d48bc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import { ProfileProvider } from "./contexts/ProfileContext"; import ErrorBoundary from "./components/shared/ErrorBoundary"; +import { initLogCapture } from "./services/logService"; import "./i18n/config"; import "./styles.css"; +initLogCapture(); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c7aa8a1..a8d1a12 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -18,6 +18,7 @@ import { Link } from "react-router-dom"; import { APP_NAME } from "../shared/constants"; import { PageHelp } from "../components/shared/PageHelp"; import DataManagementCard from "../components/settings/DataManagementCard"; +import LogViewerCard from "../components/settings/LogViewerCard"; export default function SettingsPage() { const { t } = useTranslation(); @@ -221,6 +222,9 @@ export default function SettingsPage() { )} + {/* Logs */} + + {/* Data management */} diff --git a/src/services/logService.ts b/src/services/logService.ts new file mode 100644 index 0000000..96068f4 --- /dev/null +++ b/src/services/logService.ts @@ -0,0 +1,73 @@ +export type LogLevel = "info" | "warn" | "error"; + +export interface LogEntry { + timestamp: number; + level: LogLevel; + message: string; +} + +type LogListener = () => void; + +const MAX_ENTRIES = 500; +const logs: LogEntry[] = []; +const listeners = new Set(); + +let initialized = false; + +function addEntry(level: LogLevel, args: unknown[]) { + const message = args + .map((a) => { + if (typeof a === "string") return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); + + logs.push({ timestamp: Date.now(), level, message }); + if (logs.length > MAX_ENTRIES) { + logs.splice(0, logs.length - MAX_ENTRIES); + } + + listeners.forEach((fn) => fn()); +} + +export function initLogCapture() { + if (initialized) return; + initialized = true; + + const origLog = console.log.bind(console); + const origWarn = console.warn.bind(console); + const origError = console.error.bind(console); + + console.log = (...args: unknown[]) => { + addEntry("info", args); + origLog(...args); + }; + + console.warn = (...args: unknown[]) => { + addEntry("warn", args); + origWarn(...args); + }; + + console.error = (...args: unknown[]) => { + addEntry("error", args); + origError(...args); + }; +} + +export function getLogs(): readonly LogEntry[] { + return logs; +} + +export function clearLogs() { + logs.length = 0; + listeners.forEach((fn) => fn()); +} + +export function subscribe(listener: LogListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +}