From 5a1dd61339bb72860e3642ba85fff1bb9cf99c82 Mon Sep 17 00:00:00 2001 From: Vitor Hervatin <54643926+vhervatin@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:00:11 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20release-awareness=20=E2=80=94=20update?= =?UTF-8?q?=20banner=20+=20changelog=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'new version available' banner on the home screen and an admin 'What's New' changelog page listing recent Paca releases from GitHub. - Backend: public GET /api/v1/version and /api/v1/releases, backed by a best-effort GitHub Releases client with Valkey caching. Config via ReleaseConfig (PACA_VERSION / RELEASE_UPSTREAM_REPO / RELEASE_FORK_REPO / cache TTL / timeout); all env-driven with safe defaults. - Frontend: UpdateBanner on home; changelog page under Administration (sidebar 'What's New'); version-api / changelog-api query hooks. - i18n: new keys (home.updateBanner, nav.changelog, admin.changelog) across all 9 locales. English stays the default. Everything is best-effort: if the GitHub API is unreachable the banner simply doesn't render and the page lists nothing. Co-Authored-By: Claude Opus 4.8 --- .../src/components/app-shell/app-sidebar.tsx | 6 + apps/web/src/components/home/UpdateBanner.tsx | 98 ++++++ apps/web/src/i18n/locales/en/admin.json | 7 + apps/web/src/i18n/locales/en/appShell.json | 3 +- apps/web/src/i18n/locales/en/shared.json | 8 + apps/web/src/i18n/locales/es/admin.json | 7 + apps/web/src/i18n/locales/es/appShell.json | 3 +- apps/web/src/i18n/locales/es/shared.json | 8 + apps/web/src/i18n/locales/fr/admin.json | 7 + apps/web/src/i18n/locales/fr/appShell.json | 3 +- apps/web/src/i18n/locales/fr/shared.json | 8 + apps/web/src/i18n/locales/ja/admin.json | 7 + apps/web/src/i18n/locales/ja/appShell.json | 3 +- apps/web/src/i18n/locales/ja/shared.json | 8 + apps/web/src/i18n/locales/ko/admin.json | 7 + apps/web/src/i18n/locales/ko/appShell.json | 3 +- apps/web/src/i18n/locales/ko/shared.json | 8 + apps/web/src/i18n/locales/pt-BR/admin.json | 7 + apps/web/src/i18n/locales/pt-BR/appShell.json | 3 +- apps/web/src/i18n/locales/pt-BR/shared.json | 8 + apps/web/src/i18n/locales/ru/admin.json | 7 + apps/web/src/i18n/locales/ru/appShell.json | 3 +- apps/web/src/i18n/locales/ru/shared.json | 8 + apps/web/src/i18n/locales/vi/admin.json | 7 + apps/web/src/i18n/locales/vi/appShell.json | 3 +- apps/web/src/i18n/locales/vi/shared.json | 8 + apps/web/src/i18n/locales/zh-CN/admin.json | 7 + apps/web/src/i18n/locales/zh-CN/appShell.json | 3 +- apps/web/src/i18n/locales/zh-CN/shared.json | 8 + apps/web/src/lib/changelog-api.ts | 42 +++ apps/web/src/lib/version-api.ts | 38 +++ apps/web/src/routeTree.gen.ts | 22 ++ .../_authenticated/admin/changelog/index.tsx | 244 ++++++++++++++ .../src/routes/_authenticated/home/index.tsx | 3 +- services/api/.env.example | 16 + services/api/internal/bootstrap/app.go | 1 + services/api/internal/config/config.go | 23 ++ services/api/internal/config/load.go | 16 + .../transport/http/handler/version_handler.go | 298 ++++++++++++++++++ .../internal/transport/http/router/router.go | 7 + 40 files changed, 966 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/home/UpdateBanner.tsx create mode 100644 apps/web/src/lib/changelog-api.ts create mode 100644 apps/web/src/lib/version-api.ts create mode 100644 apps/web/src/routes/_authenticated/admin/changelog/index.tsx create mode 100644 services/api/internal/transport/http/handler/version_handler.go diff --git a/apps/web/src/components/app-shell/app-sidebar.tsx b/apps/web/src/components/app-shell/app-sidebar.tsx index cfb68170..8e930d08 100644 --- a/apps/web/src/components/app-shell/app-sidebar.tsx +++ b/apps/web/src/components/app-shell/app-sidebar.tsx @@ -28,6 +28,7 @@ import { Puzzle, Settings, Shield, + Sparkles, Sun, Trash2, Users, @@ -1502,6 +1503,11 @@ export function AppSidebar() { exact /> ) : null} + diff --git a/apps/web/src/components/home/UpdateBanner.tsx b/apps/web/src/components/home/UpdateBanner.tsx new file mode 100644 index 00000000..11f3f214 --- /dev/null +++ b/apps/web/src/components/home/UpdateBanner.tsx @@ -0,0 +1,98 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowUpRight, Sparkles, X } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { type ReleaseInfo, versionQueryOptions } from "@/lib/version-api"; + +const DISMISS_KEY = "paca-update-banner-dismissed"; + +function readDismissed(): string | null { + try { + return window.localStorage.getItem(DISMISS_KEY); + } catch { + return null; + } +} + +/** + * Home banner that surfaces newer releases of the upstream project and/or the + * fork. Rendered only when the API reports an available update; dismissible per + * release set (a new release re-shows it). + */ +export function UpdateBanner() { + const { t } = useTranslation("shared"); + const { data } = useQuery(versionQueryOptions()); + const [dismissed, setDismissed] = useState(readDismissed); + + const updates: { kind: "upstream" | "fork"; info: ReleaseInfo }[] = []; + if (data?.upstream?.hasUpdate) { + updates.push({ kind: "upstream", info: data.upstream }); + } + if (data?.fork?.hasUpdate) { + updates.push({ kind: "fork", info: data.fork }); + } + + if (updates.length === 0) { + return null; + } + + // Encode the set of latest versions so dismissing hides only this exact set; + // a later release produces a new signature and shows the banner again. + const signature = updates.map((u) => u.info.latest).join(","); + if (dismissed === signature) { + return null; + } + + const dismiss = () => { + try { + window.localStorage.setItem(DISMISS_KEY, signature); + } catch { + // ignore storage failures — dismissal just won't persist + } + setDismissed(signature); + }; + + return ( +
+ +
+
+ + {t("home.updateBanner.title")} + + + {t("home.updateBanner.current", { version: data?.current })} + +
+ +
+ +
+ ); +} diff --git a/apps/web/src/i18n/locales/en/admin.json b/apps/web/src/i18n/locales/en/admin.json index 2666fef2..de7c3773 100644 --- a/apps/web/src/i18n/locales/en/admin.json +++ b/apps/web/src/i18n/locales/en/admin.json @@ -261,5 +261,12 @@ "title": "Extension Point Layout", "description": "Drag to reorder plugin panels within each extension point. Toggle visibility to show or hide panels for all users." } + }, + "changelog": { + "title": "What's New", + "description": "Follow the improvements and highlights from the latest Paca releases.", + "current": "Current version", + "error": "Couldn't load the changelog right now. Please try again later.", + "empty": "No release notes available yet." } } diff --git a/apps/web/src/i18n/locales/en/appShell.json b/apps/web/src/i18n/locales/en/appShell.json index 16800650..60e287a5 100644 --- a/apps/web/src/i18n/locales/en/appShell.json +++ b/apps/web/src/i18n/locales/en/appShell.json @@ -27,7 +27,8 @@ "conversations": "Conversations", "automation": "Automation", "team": "Team", - "settings": "Settings" + "settings": "Settings", + "changelog": "What's New" }, "projectSwitcher": { "projects": "Projects", diff --git a/apps/web/src/i18n/locales/en/shared.json b/apps/web/src/i18n/locales/en/shared.json index 412be199..81882fcc 100644 --- a/apps/web/src/i18n/locales/en/shared.json +++ b/apps/web/src/i18n/locales/en/shared.json @@ -25,6 +25,14 @@ "added": "Added" }, "home": { + "updateBanner": { + "title": "Update available", + "upstream": "Paca released {{version}}.", + "fork": "Your version {{version}} is available.", + "current": "You're on {{version}}.", + "viewRelease": "View release", + "dismiss": "Dismiss" + }, "greeting": { "morning": "Good morning", "afternoon": "Good afternoon", diff --git a/apps/web/src/i18n/locales/es/admin.json b/apps/web/src/i18n/locales/es/admin.json index 26183e10..21fa8322 100644 --- a/apps/web/src/i18n/locales/es/admin.json +++ b/apps/web/src/i18n/locales/es/admin.json @@ -261,5 +261,12 @@ "title": "Diseño de puntos de extensión", "description": "Arrastra para reordenar los paneles de plugins dentro de cada punto de extensión. Activa o desactiva la visibilidad para mostrar u ocultar paneles para todos los usuarios." } + }, + "changelog": { + "title": "Novedades", + "description": "Sigue las mejoras y novedades de las últimas versiones de Paca.", + "current": "Versión actual", + "error": "No se pudieron cargar las novedades en este momento. Inténtalo de nuevo más tarde.", + "empty": "Aún no hay notas de versión disponibles." } } diff --git a/apps/web/src/i18n/locales/es/appShell.json b/apps/web/src/i18n/locales/es/appShell.json index 0340d782..e34fedbc 100644 --- a/apps/web/src/i18n/locales/es/appShell.json +++ b/apps/web/src/i18n/locales/es/appShell.json @@ -27,7 +27,8 @@ "conversations": "Conversaciones", "automation": "Automatización", "team": "Equipo", - "settings": "Configuración" + "settings": "Configuración", + "changelog": "Novedades" }, "projectSwitcher": { "projects": "Proyectos", diff --git a/apps/web/src/i18n/locales/es/shared.json b/apps/web/src/i18n/locales/es/shared.json index 0a8129f9..831e6d07 100644 --- a/apps/web/src/i18n/locales/es/shared.json +++ b/apps/web/src/i18n/locales/es/shared.json @@ -25,6 +25,14 @@ "added": "Añadido" }, "home": { + "updateBanner": { + "title": "Actualización disponible", + "upstream": "Paca lanzó la versión {{version}}.", + "fork": "Tu versión {{version}} está disponible.", + "current": "Estás en la {{version}}.", + "viewRelease": "Ver versión", + "dismiss": "Descartar" + }, "greeting": { "morning": "Buenos días", "afternoon": "Buenas tardes", diff --git a/apps/web/src/i18n/locales/fr/admin.json b/apps/web/src/i18n/locales/fr/admin.json index 47b75f9b..9ce4393d 100644 --- a/apps/web/src/i18n/locales/fr/admin.json +++ b/apps/web/src/i18n/locales/fr/admin.json @@ -261,5 +261,12 @@ "title": "Disposition des points d'extension", "description": "Faites glisser pour réorganiser les panneaux de plugins au sein de chaque point d'extension. Activez ou désactivez la visibilité pour afficher ou masquer les panneaux pour tous les utilisateurs." } + }, + "changelog": { + "title": "Nouveautés", + "description": "Suivez les améliorations et les nouveautés des dernières versions de Paca.", + "current": "Version actuelle", + "error": "Impossible de charger les nouveautés pour le moment. Veuillez réessayer plus tard.", + "empty": "Aucune note de version disponible pour le moment." } } diff --git a/apps/web/src/i18n/locales/fr/appShell.json b/apps/web/src/i18n/locales/fr/appShell.json index 2243a7eb..d2a78297 100644 --- a/apps/web/src/i18n/locales/fr/appShell.json +++ b/apps/web/src/i18n/locales/fr/appShell.json @@ -27,7 +27,8 @@ "conversations": "Conversations", "automation": "Automatisation", "team": "Équipe", - "settings": "Paramètres" + "settings": "Paramètres", + "changelog": "Nouveautés" }, "projectSwitcher": { "projects": "Projets", diff --git a/apps/web/src/i18n/locales/fr/shared.json b/apps/web/src/i18n/locales/fr/shared.json index 2aa60e17..e3e61925 100644 --- a/apps/web/src/i18n/locales/fr/shared.json +++ b/apps/web/src/i18n/locales/fr/shared.json @@ -25,6 +25,14 @@ "added": "Ajouté" }, "home": { + "updateBanner": { + "title": "Mise à jour disponible", + "upstream": "Paca a publié la version {{version}}.", + "fork": "Votre version {{version}} est disponible.", + "current": "Vous utilisez la {{version}}.", + "viewRelease": "Voir la version", + "dismiss": "Ignorer" + }, "greeting": { "morning": "Bonjour", "afternoon": "Bon après-midi", diff --git a/apps/web/src/i18n/locales/ja/admin.json b/apps/web/src/i18n/locales/ja/admin.json index 46c6353c..a3ba7fce 100644 --- a/apps/web/src/i18n/locales/ja/admin.json +++ b/apps/web/src/i18n/locales/ja/admin.json @@ -261,5 +261,12 @@ "title": "拡張ポイントレイアウト", "description": "ドラッグして各拡張ポイント内のプラグインパネルを並べ替えます。表示設定を切り替えて、全ユーザーに対するパネルの表示・非表示を制御します。" } + }, + "changelog": { + "title": "新着情報", + "description": "Paca の最新リリースの改善点やハイライトをチェックしましょう。", + "current": "現在のバージョン", + "error": "現在、変更履歴を読み込めませんでした。しばらくしてからもう一度お試しください。", + "empty": "まだリリースノートはありません。" } } diff --git a/apps/web/src/i18n/locales/ja/appShell.json b/apps/web/src/i18n/locales/ja/appShell.json index 0d1acb33..a90cefd9 100644 --- a/apps/web/src/i18n/locales/ja/appShell.json +++ b/apps/web/src/i18n/locales/ja/appShell.json @@ -27,7 +27,8 @@ "conversations": "会話", "automation": "自動化", "team": "チーム", - "settings": "設定" + "settings": "設定", + "changelog": "新着情報" }, "projectSwitcher": { "projects": "プロジェクト", diff --git a/apps/web/src/i18n/locales/ja/shared.json b/apps/web/src/i18n/locales/ja/shared.json index 6c53bb0e..a22f5d62 100644 --- a/apps/web/src/i18n/locales/ja/shared.json +++ b/apps/web/src/i18n/locales/ja/shared.json @@ -25,6 +25,14 @@ "added": "追加" }, "home": { + "updateBanner": { + "title": "アップデートがあります", + "upstream": "Paca が {{version}} をリリースしました。", + "fork": "あなたのバージョン {{version}} が利用可能です。", + "current": "現在 {{version}} を使用中です。", + "viewRelease": "リリースを見る", + "dismiss": "閉じる" + }, "greeting": { "morning": "おはようございます", "afternoon": "こんにちは", diff --git a/apps/web/src/i18n/locales/ko/admin.json b/apps/web/src/i18n/locales/ko/admin.json index 23743c5c..6f76f343 100644 --- a/apps/web/src/i18n/locales/ko/admin.json +++ b/apps/web/src/i18n/locales/ko/admin.json @@ -261,5 +261,12 @@ "title": "확장 지점 레이아웃", "description": "각 확장 지점 내에서 플러그인 패널을 드래그하여 순서를 변경하세요. 표시 여부를 전환하여 모든 사용자에게 패널을 보이거나 숨길 수 있습니다." } + }, + "changelog": { + "title": "새로운 소식", + "description": "Paca 최신 릴리스의 개선 사항과 주요 내용을 확인하세요.", + "current": "현재 버전", + "error": "지금은 변경 내역을 불러올 수 없습니다. 나중에 다시 시도해 주세요.", + "empty": "아직 릴리스 노트가 없습니다." } } diff --git a/apps/web/src/i18n/locales/ko/appShell.json b/apps/web/src/i18n/locales/ko/appShell.json index d41643dd..b64cab13 100644 --- a/apps/web/src/i18n/locales/ko/appShell.json +++ b/apps/web/src/i18n/locales/ko/appShell.json @@ -27,7 +27,8 @@ "conversations": "대화", "automation": "자동화", "team": "팀", - "settings": "설정" + "settings": "설정", + "changelog": "새로운 소식" }, "projectSwitcher": { "projects": "프로젝트", diff --git a/apps/web/src/i18n/locales/ko/shared.json b/apps/web/src/i18n/locales/ko/shared.json index 17d5716e..9df99c09 100644 --- a/apps/web/src/i18n/locales/ko/shared.json +++ b/apps/web/src/i18n/locales/ko/shared.json @@ -25,6 +25,14 @@ "added": "추가됨" }, "home": { + "updateBanner": { + "title": "업데이트 사용 가능", + "upstream": "Paca가 {{version}}을(를) 출시했습니다.", + "fork": "버전 {{version}}을(를) 사용할 수 있습니다.", + "current": "현재 {{version}}을(를) 사용 중입니다.", + "viewRelease": "릴리스 보기", + "dismiss": "닫기" + }, "greeting": { "morning": "좋은 아침이에요", "afternoon": "안녕하세요", diff --git a/apps/web/src/i18n/locales/pt-BR/admin.json b/apps/web/src/i18n/locales/pt-BR/admin.json index 532bf420..87fc55a0 100644 --- a/apps/web/src/i18n/locales/pt-BR/admin.json +++ b/apps/web/src/i18n/locales/pt-BR/admin.json @@ -261,5 +261,12 @@ "title": "Layout de Pontos de Extensão", "description": "Arraste para reordenar os painéis de plugins dentro de cada ponto de extensão. Alterne a visibilidade para mostrar ou ocultar painéis para todos os usuários." } + }, + "changelog": { + "title": "Novidades", + "description": "Acompanhe as melhorias e novidades das últimas versões do Paca.", + "current": "Versão atual", + "error": "Não foi possível carregar as novidades agora. Tente novamente mais tarde.", + "empty": "Nenhuma novidade disponível no momento." } } diff --git a/apps/web/src/i18n/locales/pt-BR/appShell.json b/apps/web/src/i18n/locales/pt-BR/appShell.json index e40cb2f5..dca4525e 100644 --- a/apps/web/src/i18n/locales/pt-BR/appShell.json +++ b/apps/web/src/i18n/locales/pt-BR/appShell.json @@ -27,7 +27,8 @@ "conversations": "Conversas", "automation": "Automação", "team": "Equipe", - "settings": "Configurações" + "settings": "Configurações", + "changelog": "Novidades" }, "projectSwitcher": { "projects": "Projetos", diff --git a/apps/web/src/i18n/locales/pt-BR/shared.json b/apps/web/src/i18n/locales/pt-BR/shared.json index 30d17e01..4126a41d 100644 --- a/apps/web/src/i18n/locales/pt-BR/shared.json +++ b/apps/web/src/i18n/locales/pt-BR/shared.json @@ -25,6 +25,14 @@ "added": "Adicionado" }, "home": { + "updateBanner": { + "title": "Atualização disponível", + "upstream": "O Paca lançou a versão {{version}}.", + "fork": "Sua versão {{version}} está disponível.", + "current": "Você está na {{version}}.", + "viewRelease": "Ver release", + "dismiss": "Dispensar" + }, "greeting": { "morning": "Bom dia", "afternoon": "Boa tarde", diff --git a/apps/web/src/i18n/locales/ru/admin.json b/apps/web/src/i18n/locales/ru/admin.json index 6e81b866..eee3f882 100644 --- a/apps/web/src/i18n/locales/ru/admin.json +++ b/apps/web/src/i18n/locales/ru/admin.json @@ -267,5 +267,12 @@ "title": "Раскладка точек расширения", "description": "Перетаскивайте панели плагинов внутри каждой точки расширения. Переключайте видимость, чтобы показывать или скрывать панели для всех пользователей." } + }, + "changelog": { + "title": "Что нового", + "description": "Следите за улучшениями и главными изменениями в последних версиях Paca.", + "current": "Текущая версия", + "error": "Не удалось загрузить список изменений. Пожалуйста, повторите попытку позже.", + "empty": "Пока нет заметок о выпусках." } } diff --git a/apps/web/src/i18n/locales/ru/appShell.json b/apps/web/src/i18n/locales/ru/appShell.json index af427601..df7cc2c3 100644 --- a/apps/web/src/i18n/locales/ru/appShell.json +++ b/apps/web/src/i18n/locales/ru/appShell.json @@ -27,7 +27,8 @@ "conversations": "Диалоги", "automation": "Автоматизация", "team": "Команда", - "settings": "Настройки" + "settings": "Настройки", + "changelog": "Что нового" }, "projectSwitcher": { "projects": "Проекты", diff --git a/apps/web/src/i18n/locales/ru/shared.json b/apps/web/src/i18n/locales/ru/shared.json index fac67469..5a9a49d2 100644 --- a/apps/web/src/i18n/locales/ru/shared.json +++ b/apps/web/src/i18n/locales/ru/shared.json @@ -25,6 +25,14 @@ "added": "Добавлено" }, "home": { + "updateBanner": { + "title": "Доступно обновление", + "upstream": "Paca выпустил версию {{version}}.", + "fork": "Доступна ваша версия {{version}}.", + "current": "У вас установлена {{version}}.", + "viewRelease": "Посмотреть выпуск", + "dismiss": "Закрыть" + }, "greeting": { "morning": "Доброе утро", "afternoon": "Добрый день", diff --git a/apps/web/src/i18n/locales/vi/admin.json b/apps/web/src/i18n/locales/vi/admin.json index bdf13a4e..20e24781 100644 --- a/apps/web/src/i18n/locales/vi/admin.json +++ b/apps/web/src/i18n/locales/vi/admin.json @@ -261,5 +261,12 @@ "title": "Bố cục điểm mở rộng", "description": "Kéo để sắp xếp lại các bảng plugin trong từng điểm mở rộng. Bật/tắt khả năng hiển thị để hiện hoặc ẩn bảng cho tất cả người dùng." } + }, + "changelog": { + "title": "Có gì mới", + "description": "Theo dõi các cải tiến và điểm nổi bật từ những phiên bản Paca mới nhất.", + "current": "Phiên bản hiện tại", + "error": "Hiện không thể tải nhật ký thay đổi. Vui lòng thử lại sau.", + "empty": "Chưa có ghi chú phát hành nào." } } diff --git a/apps/web/src/i18n/locales/vi/appShell.json b/apps/web/src/i18n/locales/vi/appShell.json index 68e7c6b2..dd2db6ee 100644 --- a/apps/web/src/i18n/locales/vi/appShell.json +++ b/apps/web/src/i18n/locales/vi/appShell.json @@ -27,7 +27,8 @@ "conversations": "Hội thoại", "automation": "Tự động hóa", "team": "Đội nhóm", - "settings": "Cài đặt" + "settings": "Cài đặt", + "changelog": "Có gì mới" }, "projectSwitcher": { "projects": "Dự án", diff --git a/apps/web/src/i18n/locales/vi/shared.json b/apps/web/src/i18n/locales/vi/shared.json index adafaaf0..11e7f769 100644 --- a/apps/web/src/i18n/locales/vi/shared.json +++ b/apps/web/src/i18n/locales/vi/shared.json @@ -25,6 +25,14 @@ "added": "Đã thêm" }, "home": { + "updateBanner": { + "title": "Có bản cập nhật", + "upstream": "Paca đã phát hành {{version}}.", + "fork": "Phiên bản {{version}} của bạn đã có sẵn.", + "current": "Bạn đang dùng {{version}}.", + "viewRelease": "Xem bản phát hành", + "dismiss": "Bỏ qua" + }, "greeting": { "morning": "Chào buổi sáng", "afternoon": "Chào buổi chiều", diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index a969e69e..5bba9f16 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -261,5 +261,12 @@ "title": "扩展点布局", "description": "拖动以重新排列各扩展点内的插件面板。切换可见性以为所有用户显示或隐藏面板。" } + }, + "changelog": { + "title": "最新动态", + "description": "关注 Paca 最新版本的改进与亮点。", + "current": "当前版本", + "error": "暂时无法加载更新日志,请稍后再试。", + "empty": "暂无发布说明。" } } diff --git a/apps/web/src/i18n/locales/zh-CN/appShell.json b/apps/web/src/i18n/locales/zh-CN/appShell.json index 91a27bf8..e86f18b0 100644 --- a/apps/web/src/i18n/locales/zh-CN/appShell.json +++ b/apps/web/src/i18n/locales/zh-CN/appShell.json @@ -27,7 +27,8 @@ "conversations": "对话", "automation": "自动化", "team": "团队", - "settings": "设置" + "settings": "设置", + "changelog": "最新动态" }, "projectSwitcher": { "projects": "项目", diff --git a/apps/web/src/i18n/locales/zh-CN/shared.json b/apps/web/src/i18n/locales/zh-CN/shared.json index 24bffbf2..5bd64e0c 100644 --- a/apps/web/src/i18n/locales/zh-CN/shared.json +++ b/apps/web/src/i18n/locales/zh-CN/shared.json @@ -25,6 +25,14 @@ "added": "已添加" }, "home": { + "updateBanner": { + "title": "有可用更新", + "upstream": "Paca 发布了 {{version}}。", + "fork": "您的版本 {{version}} 已可用。", + "current": "您当前使用 {{version}}。", + "viewRelease": "查看发布", + "dismiss": "关闭" + }, "greeting": { "morning": "早上好", "afternoon": "下午好", diff --git a/apps/web/src/lib/changelog-api.ts b/apps/web/src/lib/changelog-api.ts new file mode 100644 index 00000000..fc182597 --- /dev/null +++ b/apps/web/src/lib/changelog-api.ts @@ -0,0 +1,42 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiClient } from "./api-client"; +import type { SuccessEnvelope } from "./api-error"; + +// ── Shapes ──────────────────────────────────────────────────────────────────── + +export interface ReleaseEntry { + tag: string; + name: string; + url: string; + publishedAt: string; + body: string; + isCurrent: boolean; +} + +export interface ReleasesResponse { + current: string; + repo: string; + releases: ReleaseEntry[]; +} + +// ── Query ─────────────────────────────────────────────────────────────────── + +export async function getReleases(): Promise { + const { data } = + await apiClient.instance.get>( + "/releases", + ); + return data.data; +} + +export const releasesQueryOptions = () => + queryOptions({ + queryKey: ["releases"], + queryFn: getReleases, + // The backend caches the GitHub response; keep the client copy fresh for + // a while and avoid refetching on every window focus. + staleTime: 1000 * 60 * 30, + refetchOnWindowFocus: false, + retry: false, + }); diff --git a/apps/web/src/lib/version-api.ts b/apps/web/src/lib/version-api.ts new file mode 100644 index 00000000..3353fec9 --- /dev/null +++ b/apps/web/src/lib/version-api.ts @@ -0,0 +1,38 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiClient } from "./api-client"; +import type { SuccessEnvelope } from "./api-error"; + +// ── Shapes ──────────────────────────────────────────────────────────────────── + +export interface ReleaseInfo { + repo: string; + latest: string; + url: string; + hasUpdate: boolean; +} + +export interface VersionInfo { + current: string; + upstream?: ReleaseInfo; + fork?: ReleaseInfo; +} + +// ── Query ─────────────────────────────────────────────────────────────────── + +export async function getVersion(): Promise { + const { data } = + await apiClient.instance.get>("/version"); + return data.data; +} + +export const versionQueryOptions = () => + queryOptions({ + queryKey: ["version"], + queryFn: getVersion, + // The backend caches the GitHub response; keep the client copy fresh for + // a while and don't refetch on every window focus. + staleTime: 1000 * 60 * 30, + refetchOnWindowFocus: false, + retry: false, + }); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f1252d75..a669dd6f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as AuthenticatedProjectsProjectIdIndexRouteImport } from './route import { Route as AuthenticatedAdminUsersIndexRouteImport } from './routes/_authenticated/admin/users/index' import { Route as AuthenticatedAdminPluginsIndexRouteImport } from './routes/_authenticated/admin/plugins/index' import { Route as AuthenticatedAdminGlobalRolesIndexRouteImport } from './routes/_authenticated/admin/global-roles/index' +import { Route as AuthenticatedAdminChangelogIndexRouteImport } from './routes/_authenticated/admin/changelog/index' import { Route as AuthenticatedProjectsProjectIdConversationsRouteImport } from './routes/_authenticated/projects/$projectId/conversations' import { Route as AuthenticatedProjectsProjectIdTeamIndexRouteImport } from './routes/_authenticated/projects/$projectId/team/index' import { Route as AuthenticatedProjectsProjectIdSettingsIndexRouteImport } from './routes/_authenticated/projects/$projectId/settings/index' @@ -99,6 +100,12 @@ const AuthenticatedAdminGlobalRolesIndexRoute = path: '/admin/global-roles/', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedAdminChangelogIndexRoute = + AuthenticatedAdminChangelogIndexRouteImport.update({ + id: '/admin/changelog/', + path: '/admin/changelog/', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedProjectsProjectIdConversationsRoute = AuthenticatedProjectsProjectIdConversationsRouteImport.update({ id: '/conversations', @@ -210,6 +217,7 @@ export interface FileRoutesByFullPath { '/home/': typeof AuthenticatedHomeIndexRoute '/profile/': typeof AuthenticatedProfileIndexRoute '/projects/$projectId/conversations': typeof AuthenticatedProjectsProjectIdConversationsRouteWithChildren + '/admin/changelog/': typeof AuthenticatedAdminChangelogIndexRoute '/admin/global-roles/': typeof AuthenticatedAdminGlobalRolesIndexRoute '/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute '/admin/users/': typeof AuthenticatedAdminUsersIndexRoute @@ -237,6 +245,7 @@ export interface FileRoutesByTo { '/profile/api-keys': typeof AuthenticatedProfileApiKeysRoute '/home': typeof AuthenticatedHomeIndexRoute '/profile': typeof AuthenticatedProfileIndexRoute + '/admin/changelog': typeof AuthenticatedAdminChangelogIndexRoute '/admin/global-roles': typeof AuthenticatedAdminGlobalRolesIndexRoute '/admin/plugins': typeof AuthenticatedAdminPluginsIndexRoute '/admin/users': typeof AuthenticatedAdminUsersIndexRoute @@ -268,6 +277,7 @@ export interface FileRoutesById { '/_authenticated/home/': typeof AuthenticatedHomeIndexRoute '/_authenticated/profile/': typeof AuthenticatedProfileIndexRoute '/_authenticated/projects/$projectId/conversations': typeof AuthenticatedProjectsProjectIdConversationsRouteWithChildren + '/_authenticated/admin/changelog/': typeof AuthenticatedAdminChangelogIndexRoute '/_authenticated/admin/global-roles/': typeof AuthenticatedAdminGlobalRolesIndexRoute '/_authenticated/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute '/_authenticated/admin/users/': typeof AuthenticatedAdminUsersIndexRoute @@ -299,6 +309,7 @@ export interface FileRouteTypes { | '/home/' | '/profile/' | '/projects/$projectId/conversations' + | '/admin/changelog/' | '/admin/global-roles/' | '/admin/plugins/' | '/admin/users/' @@ -326,6 +337,7 @@ export interface FileRouteTypes { | '/profile/api-keys' | '/home' | '/profile' + | '/admin/changelog' | '/admin/global-roles' | '/admin/plugins' | '/admin/users' @@ -356,6 +368,7 @@ export interface FileRouteTypes { | '/_authenticated/home/' | '/_authenticated/profile/' | '/_authenticated/projects/$projectId/conversations' + | '/_authenticated/admin/changelog/' | '/_authenticated/admin/global-roles/' | '/_authenticated/admin/plugins/' | '/_authenticated/admin/users/' @@ -463,6 +476,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedAdminGlobalRolesIndexRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/admin/changelog/': { + id: '/_authenticated/admin/changelog/' + path: '/admin/changelog' + fullPath: '/admin/changelog/' + preLoaderRoute: typeof AuthenticatedAdminChangelogIndexRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/projects/$projectId/conversations': { id: '/_authenticated/projects/$projectId/conversations' path: '/conversations' @@ -665,6 +685,7 @@ interface AuthenticatedRouteChildren { AuthenticatedProjectsProjectIdRoute: typeof AuthenticatedProjectsProjectIdRouteWithChildren AuthenticatedHomeIndexRoute: typeof AuthenticatedHomeIndexRoute AuthenticatedProfileIndexRoute: typeof AuthenticatedProfileIndexRoute + AuthenticatedAdminChangelogIndexRoute: typeof AuthenticatedAdminChangelogIndexRoute AuthenticatedAdminGlobalRolesIndexRoute: typeof AuthenticatedAdminGlobalRolesIndexRoute AuthenticatedAdminPluginsIndexRoute: typeof AuthenticatedAdminPluginsIndexRoute AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute @@ -677,6 +698,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedProjectsProjectIdRouteWithChildren, AuthenticatedHomeIndexRoute: AuthenticatedHomeIndexRoute, AuthenticatedProfileIndexRoute: AuthenticatedProfileIndexRoute, + AuthenticatedAdminChangelogIndexRoute: AuthenticatedAdminChangelogIndexRoute, AuthenticatedAdminGlobalRolesIndexRoute: AuthenticatedAdminGlobalRolesIndexRoute, AuthenticatedAdminPluginsIndexRoute: AuthenticatedAdminPluginsIndexRoute, diff --git a/apps/web/src/routes/_authenticated/admin/changelog/index.tsx b/apps/web/src/routes/_authenticated/admin/changelog/index.tsx new file mode 100644 index 00000000..351d1514 --- /dev/null +++ b/apps/web/src/routes/_authenticated/admin/changelog/index.tsx @@ -0,0 +1,244 @@ +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { ExternalLink, Sparkles } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { type ReleaseEntry, releasesQueryOptions } from "@/lib/changelog-api"; + +export const Route = createFileRoute("/_authenticated/admin/changelog/")({ + component: ChangelogPage, +}); + +// ── Minimal, safe markdown rendering for GitHub release notes ──────────────── +// Handles the common cases (headings, bullet lists, **bold**, `code`, links) +// without pulling in a markdown dependency or using dangerouslySetInnerHTML. + +// Only http(s)/mailto links are allowed in release notes; anything else +// (javascript:, data:, …) is rendered as plain text to avoid XSS. +function safeHref(url: string): string | undefined { + try { + const u = new URL(url, window.location.origin); + if ( + u.protocol === "http:" || + u.protocol === "https:" || + u.protocol === "mailto:" + ) { + return url; + } + } catch { + // invalid URL → not rendered as a link + } + return undefined; +} + +function renderInline(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + const regex = /(\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g; + let last = 0; + let key = 0; + let m: RegExpExecArray | null = regex.exec(text); + while (m !== null) { + if (m.index > last) nodes.push(text.slice(last, m.index)); + if (m[2] !== undefined) { + nodes.push({m[2]}); + } else if (m[3] !== undefined) { + nodes.push( + + {m[3]} + , + ); + } else if (m[4] !== undefined) { + const href = safeHref(m[5] ?? ""); + if (href) { + nodes.push( + + {m[4]} + , + ); + } else { + nodes.push(m[4]); + } + } + last = m.index + m[0].length; + m = regex.exec(text); + } + if (last < text.length) nodes.push(text.slice(last)); + return nodes; +} + +function renderNotes(body: string): ReactNode { + const lines = body.replace(/\r\n/g, "\n").split("\n"); + const blocks: ReactNode[] = []; + let bullets: ReactNode[] = []; + + const flush = () => { + if (bullets.length) { + blocks.push( +
    + {bullets} +
, + ); + bullets = []; + } + }; + + lines.forEach((line) => { + const t = line.trim(); + if (!t) { + flush(); + return; + } + if (/^#{1,6}\s/.test(t)) { + flush(); + blocks.push( +

+ {renderInline(t.replace(/^#{1,6}\s/, ""))} +

, + ); + return; + } + if (/^[-*]\s/.test(t)) { + bullets.push( +
  • + {renderInline(t.replace(/^[-*]\s/, ""))} +
  • , + ); + return; + } + flush(); + blocks.push( +

    + {renderInline(t)} +

    , + ); + }); + flush(); + return
    {blocks}
    ; +} + +function formatDate(iso: string, locale: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function ReleaseCard({ + release, + currentLabel, +}: { + release: ReleaseEntry; + currentLabel: string; +}) { + const { i18n } = useTranslation(); + return ( +
    + {release.isCurrent ? ( + + ) : null} +
    +

    + {release.name} +

    + {release.isCurrent ? ( + + {currentLabel} + + ) : null} + + {formatDate(release.publishedAt, i18n.language)} + + {release.url ? ( + + + + ) : null} +
    + {release.body ? ( + renderNotes(release.body) + ) : ( +

    + )} +
    + ); +} + +function ChangelogPage() { + const { t } = useTranslation("admin"); + const { data, isLoading, isError } = useQuery(releasesQueryOptions()); + + return ( +
    +
    +
    + +
    +
    +

    + {t("changelog.title")} +

    +

    + {t("changelog.description")} +

    +
    +
    + + {isLoading ? ( +
    + {[0, 1, 2].map((i) => ( + + ))} +
    + ) : isError ? ( +
    + {t("changelog.error")} +
    + ) : !data || data.releases.length === 0 ? ( +
    + {t("changelog.empty")} +
    + ) : ( +
    + {data.releases.map((release) => ( + + ))} +
    + )} +
    + ); +} diff --git a/apps/web/src/routes/_authenticated/home/index.tsx b/apps/web/src/routes/_authenticated/home/index.tsx index 47b45bf2..1280659a 100644 --- a/apps/web/src/routes/_authenticated/home/index.tsx +++ b/apps/web/src/routes/_authenticated/home/index.tsx @@ -16,8 +16,8 @@ import { } from "lucide-react"; import { type ComponentType, useState } from "react"; import { useTranslation } from "react-i18next"; - import { AssignedTasksList } from "@/components/home/assigned-tasks-list"; +import { UpdateBanner } from "@/components/home/UpdateBanner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -475,6 +475,7 @@ function HomePage() { return (
    + {/* Hero banner */}
    cv[i] + } + } + return false +} + +// parseVersion extracts the major.minor.patch core from a tag like "v0.9.4" or +// "0.9.4-evup.1". Returns ok=false when there is no numeric core to compare. +func parseVersion(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + if i := strings.IndexAny(v, "-+"); i >= 0 { + v = v[:i] + } + if v == "" { + return out, false + } + for i, part := range strings.Split(v, ".") { + if i >= 3 { + break + } + n, err := strconv.Atoi(part) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go index 24a0294d..73550469 100644 --- a/services/api/internal/transport/http/router/router.go +++ b/services/api/internal/transport/http/router/router.go @@ -23,6 +23,7 @@ type Deps struct { Authorizer *authz.Authorizer ProjectVisibilitySvc httpmw.ProjectVisibilityChecker Health *handler.HealthHandler + Version *handler.VersionHandler Auth *handler.AuthHandler User *handler.UserHandler GlobalRole *handler.GlobalRoleHandler @@ -63,6 +64,12 @@ func New(deps Deps) http.Handler { r.Get("/healthz", deps.Health.Check) r.Route("/v1", func(r chi.Router) { + // Version / update check — public, no auth required. + if deps.Version != nil { + r.Get("/version", deps.Version.Check) + r.Get("/releases", deps.Version.ListReleases) + } + // Auth r.Route("/auth", func(r chi.Router) { r.Post("/login", deps.Auth.Login)