Compare commits

..

3 commits
v0.1 ... main

Author SHA1 Message Date
maxsoch 457a6a284a feat: v0.3 improvements
- Show software version under site title
- Fix background stopping on very wide screens (move gradient to body)
- Validate Remote-User header value to avoid showing placeholder when not authenticated
- Toggle login/logout button based on auth state via login_url config
- Bump version to 0.3.0
2026-08-02 18:25:04 +02:00
maxsoch 4807899b38 feat: Sochboard logo with dynamic accent color as favicon and header icon
- New SochboardLogo SVG component used in dashboard header
- Favicon generated from theme accent color
- Bump version to 0.2.0
2026-08-02 17:53:10 +02:00
maxsoch d36f16cea1 feat: configurable icon_size (small/medium/large) 2026-07-24 14:46:51 +02:00
17 changed files with 283 additions and 74 deletions

View file

@ -11,7 +11,7 @@ Le bouton de bascule thème clair/sombre ne répond pas au toucher sur Firefox i
- Tester avec un bouton complètement isolé (hors du header, hors des composants dashboard) - Tester avec un bouton complètement isolé (hors du header, hors des composants dashboard)
- Vérifier si le problème persiste dans une page vide sans CSS gradient - Vérifier si le problème persiste dans une page vide sans CSS gradient
## Filtrage par en-têtes HTTP (ex: Authelia) ## Filtrage par en-têtes HTTP (ex: Authelia) — ✅ fait (v0.2)
Masquer/afficher des sections ou des liens en fonction des en-têtes HTTP envoyés par un reverse proxy ou un provider d'authentification (Authelia, Authentik, etc.). Masquer/afficher des sections ou des liens en fonction des en-têtes HTTP envoyés par un reverse proxy ou un provider d'authentification (Authelia, Authentik, etc.).

8
TODO.md Normal file
View file

@ -0,0 +1,8 @@
# TODO
## Sécurité
- Retirer le token Forgejo de l'URL du remote git dans `.git/config`
(actuellement en clair : `https://maxsoch:<token>@git.socheleau.fr/...`)
→ utiliser un credential helper ou SSH à la place
- Vérifier que le token n'apparaît pas ailleurs dans le repo

View file

@ -55,16 +55,18 @@ Configuration optionnelle pour l'authentification via un reverse proxy (Authelia
| Champ | Requis | Type | Défaut | Description | | Champ | Requis | Type | Défaut | Description |
|---|---|---|---|---| |---|---|---|---|---|
| `logout_url` | Oui | chaîne (URL) | — | URL de déconnexion (ex: `"https://auth.example.com/logout"`) | | `logout_url` | Oui | chaîne (URL) | — | URL de déconnexion (ex: `"https://auth.example.com/logout"`) |
| `login_url` | Non | chaîne (URL) | — | URL de connexion. Un bouton login remplace le bouton logout quand l'utilisateur n'est pas connecté (ex: `"https://auth.example.com"`) |
| `header_format` | Non | chaîne | `"Remote-User"` | Nom de l'en-tête HTTP indiquant que l'utilisateur est connecté | | `header_format` | Non | chaîne | `"Remote-User"` | Nom de l'en-tête HTTP indiquant que l'utilisateur est connecté |
| `username` | Non | chaîne | — | Nom de l'en-tête HTTP contenant le nom d'utilisateur (ex: `"Remote-Name"`). Affiche "Hi {valeur}" dans le header. | | `username` | Non | chaîne | — | Nom de l'en-tête HTTP contenant le nom d'utilisateur (ex: `"Remote-Name"`). Affiche "Hi {valeur}" dans le header. |
| `header_user` | Non | chaîne | — | Nom de l'en-tête HTTP pour filtrer par utilisateur (ex: `"Remote-User"`) | | `header_user` | Non | chaîne | — | Nom de l'en-tête HTTP pour filtrer par utilisateur (ex: `"Remote-User"`) |
| `header_group` | Non | chaîne | — | Nom de l'en-tête HTTP pour filtrer par groupe (ex: `"Remote-Groups"`) | | `header_group` | Non | chaîne | — | Nom de l'en-tête HTTP pour filtrer par groupe (ex: `"Remote-Groups"`) |
Quand l'en-tête défini par `header_format` est présent dans la requête, un bouton de déconnexion apparaît à droite du bouton thème. Quand l'en-tête défini par `header_format` est présent **avec une valeur valide** (non vide, sans placeholder non résolu), un bouton de déconnexion apparaît à droite du bouton thème. Sinon, si `login_url` est défini, un bouton de connexion est affiché à la place.
```yaml ```yaml
authentication: authentication:
logout_url: "https://auth.example.com/logout" logout_url: "https://auth.example.com/logout"
login_url: "https://auth.example.com"
header_format: "Remote-User" header_format: "Remote-User"
``` ```

View file

@ -1,84 +1,132 @@
site: site:
name: "Soch Family" name: "Soch Family"
title: " " title: " "
#title: "Les applications de la famille"
logoUrl: "https://immich.socheleau.fr/api/assets/e010423f-ca95-45a4-a9ee-9bb1a5a48688/thumbnail?slug=us_6&size=preview&c=IhgKJYiHg4ZfhXh6h5aJp5eAdQlJ&edited=true" logoUrl: "https://immich.socheleau.fr/api/assets/e010423f-ca95-45a4-a9ee-9bb1a5a48688/thumbnail?slug=us_6&size=preview&c=IhgKJYiHg4ZfhXh6h5aJp5eAdQlJ&edited=true"
#description: "Accès rapide à nos services"
showFooter: false showFooter: false
theme: theme:
mode: "dark" mode: "dark"
accent: "#8b5cf6" accent: "#8b5cf6"
background: "gradient" background: "gradient"
icon_size: "large"
authentication: authentication:
logout_url: "https://auth.socheleau.fr/logout" logout_url: "https://auth.socheleau.fr/logout"
login_url: "https://auth.socheleau.fr"
header_format: "Remote-User" header_format: "Remote-User"
username: "Remote-Name" username: "Remote-Name"
header_user: "Remote-User" header_user: "Remote-User"
header_group: "Remote-Groups" header_group: "Remote-Groups"
sections: sections:
- id: "global" - id: "soch-services"
title: "Global" title: "Soch's services"
#description: "Services accessibles à toute la famille"
cardLayout: "horizontal" cardLayout: "horizontal"
visible_by_group: "arr"
links: links:
- id: "nextcloud" - id: "authelia"
name: "Nextcloud" name: "Authelia"
description: "Fichiers et documents" description: "Authentification unique"
url: "https://cloud.example.com" url: "https://auth.socheleau.fr"
icon: "Cloud" icon: "Shield"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg" iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authelia.svg"
color: "#2daae1" color: "#1a7f37"
openInNewTab: false openInNewTab: false
visible_by_user: "jean"
- id: "jellyfin" - id: "plex"
name: "Jellyfin" name: "Plex"
description: "Films et séries" description: "Films et séries"
url: "https://jellyfin.example.com" url: "https://plex.socheleau.fr"
icon: "Film" icon: "Film"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg" iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg"
color: "#aa5cc3" color: "#e5a00d"
openInNewTab: false openInNewTab: false
- id: "navidrome" - id: "bitwarden"
name: "Navidrome" name: "Bitwarden"
description: "Musique" description: "Mots de passe"
url: "https://music.example.com" url: "https://bitwarden.socheleau.fr"
icon: "Music" icon: "Shield"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/navidrome.svg" iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bitwarden.svg"
color: "#22c55e" color: "#175ddc"
openInNewTab: false openInNewTab: false
- id: "minecraft" - id: "immich"
name: "Minecraft" name: "Immich"
description: "Serveur de jeu" description: "Photos et vidéos"
url: "https://minecraft.example.com" url: "https://immich.socheleau.fr"
icon: "Gamepad2" icon: "Image"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/minecraft.svg" iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/immich.svg"
color: "#f97316" color: "#4250af"
openInNewTab: false openInNewTab: false
- id: "custom" - id: "freshrss"
title: "Liens personnalisés" name: "FreshRSS"
description: "Flux RSS"
url: "https://rss.socheleau.fr"
icon: "Rss"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/freshrss.svg"
color: "#0979b0"
openInNewTab: false
- id: "tandoor"
name: "Tandoor"
description: "Recettes de cuisine"
url: "https://tandoor.socheleau.fr"
icon: "ChefHat"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tandoor-recipes.svg"
color: "#dc7b2a"
openInNewTab: false
- id: "readeck"
name: "Readeck"
description: "Lecture différée"
url: "https://readeck.socheleau.fr"
icon: "BookOpen"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readeck.svg"
color: "#14b8a6"
openInNewTab: false
- id: "homes"
title: "Homes"
cardLayout: "horizontal" cardLayout: "horizontal"
links: links:
- id: "homeassistant" - id: "arasu"
name: "Home Assistant" name: "Arasu"
description: "Domotique" description: "Domotique"
url: "https://ha.example.com" url: "https://arasu.socheleau.fr"
icon: "House" icon: "House"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg" iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg"
color: "#22c55e" color: "#22c55e"
openInNewTab: false openInNewTab: false
- id: "router" - id: "arr"
name: "Routeur" title: "*arr"
description: "Administration réseau" cardLayout: "horizontal"
url: "http://192.168.1.1" visible_by_group: "arr"
icon: "Router" links:
color: "#f59e0b" - id: "radarr"
name: "Radarr"
description: "Gestion de films"
url: "https://radarr.arr.socheleau.fr"
icon: "Clapperboard"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg"
color: "#e73a2e"
openInNewTab: false
- id: "sonarr"
name: "Sonarr"
description: "Gestion de séries"
url: "https://sonarr.arr.socheleau.fr"
icon: "Tv"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg"
color: "#35c5f4"
openInNewTab: false
- id: "prowlarr"
name: "Prowlarr"
description: "Indexeur de torrents"
url: "https://prowlarr.arr.socheleau.fr"
icon: "Search"
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg"
color: "#e65c21"
openInNewTab: false openInNewTab: false

View file

@ -1,6 +1,6 @@
services: services:
sochboard: sochboard:
build: . image: registry2.socheleau.fr/maxence/sochboard:latest
container_name: sochboard container_name: sochboard
ports: ports:
- "3000:3000" - "3000:3000"

View file

@ -1,6 +1,6 @@
{ {
"name": "sochboard", "name": "sochboard",
"version": "0.1.0", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node node_modules/next/dist/bin/next dev", "dev": "node node_modules/next/dist/bin/next dev",

View file

@ -33,12 +33,16 @@ html.theme-light {
--focus-ring-offset: #f8fafc; --focus-ring-offset: #f8fafc;
} }
.dashboard-root { body {
min-height: 100vh; min-height: 100vh;
background: var(--bg-gradient); background: var(--bg-gradient);
color: var(--text-primary); color: var(--text-primary);
} }
.dashboard-root {
min-height: 100vh;
}
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { Inter } from "next/font/google" import { Inter } from "next/font/google"
import { getConfig } from "@/lib/config" import { getConfig } from "@/lib/config"
import { SochboardLogoDataUri } from "@/components/ui/SochboardLogo"
import "./globals.css" import "./globals.css"
const inter = Inter({ const inter = Inter({
@ -15,12 +16,17 @@ export async function generateMetadata(): Promise<Metadata> {
return { return {
title: config.site.name, title: config.site.name,
description: config.site.title, description: config.site.title,
icons: config.site.favicon ? { icon: config.site.favicon } : undefined, icons: {
icon: config.site.favicon ?? SochboardLogoDataUri(config.theme.accent),
},
} }
} catch { } catch {
return { return {
title: "Sochboard", title: "Sochboard",
description: "Dashboard", description: "Dashboard",
icons: {
icon: SochboardLogoDataUri(),
},
} }
} }
} }

View file

@ -5,6 +5,14 @@ import { ErrorDisplay } from "@/components/ui/ErrorDisplay"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
function isValidHeaderValue(value: string | null | undefined): value is string {
if (!value) return false
const trimmed = value.trim()
if (trimmed.length === 0) return false
if (trimmed.includes("{") || trimmed.includes("}")) return false
return true
}
function getConfigResult() { function getConfigResult() {
try { try {
return { ok: true as const, config: getConfig() } return { ok: true as const, config: getConfig() }
@ -30,12 +38,17 @@ export default async function Home() {
const authHeader = config.authentication?.header_format ?? "Remote-User" const authHeader = config.authentication?.header_format ?? "Remote-User"
const usernameHeader = config.authentication?.username const usernameHeader = config.authentication?.username
const headersList = await headers() const headersList = await headers()
const isAuthenticated = config.authentication != null && headersList.has(authHeader) const authValue = config.authentication ? headersList.get(authHeader) : null
const username = isAuthenticated && usernameHeader ? headersList.get(usernameHeader) ?? null : null const isAuthenticated = isValidHeaderValue(authValue)
const username = isAuthenticated && usernameHeader && isValidHeaderValue(headersList.get(usernameHeader))
? headersList.get(usernameHeader)!.trim()
: null
const userHeader = config.authentication?.header_user const userHeader = config.authentication?.header_user
const groupHeader = config.authentication?.header_group const groupHeader = config.authentication?.header_group
const currentUser = isAuthenticated && userHeader ? headersList.get(userHeader) ?? null : null const currentUser = isAuthenticated && userHeader && isValidHeaderValue(headersList.get(userHeader))
? headersList.get(userHeader)!.trim()
: null
const userGroups = isAuthenticated && groupHeader const userGroups = isAuthenticated && groupHeader
? (headersList.get(groupHeader) ?? "").split(",").map((g) => g.trim()).filter(Boolean) ? (headersList.get(groupHeader) ?? "").split(",").map((g) => g.trim()).filter(Boolean)
: [] : []

View file

@ -1,5 +1,6 @@
import { DashboardHeader } from "./DashboardHeader" import { DashboardHeader } from "./DashboardHeader"
import { DashboardSection } from "./DashboardSection" import { DashboardSection } from "./DashboardSection"
import { VERSION } from "@/lib/version"
import type { DashboardConfig } from "@/types/config" import type { DashboardConfig } from "@/types/config"
interface DashboardProps { interface DashboardProps {
@ -19,10 +20,12 @@ export function Dashboard({ config, isAuthenticated = false, username = null, cu
authentication={config.authentication} authentication={config.authentication}
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
username={username} username={username}
accent={config.theme.accent}
version={VERSION}
/> />
<main className="flex flex-col gap-12"> <main className="flex flex-col gap-12">
{config.sections.map((section, index) => ( {config.sections.map((section, index) => (
<DashboardSection key={section.id} section={section} sectionIndex={index} currentUser={currentUser} userGroups={userGroups} /> <DashboardSection key={section.id} section={section} sectionIndex={index} currentUser={currentUser} userGroups={userGroups} iconSize={config.theme.icon_size} />
))} ))}
</main> </main>
{config.site.showFooter !== false && ( {config.site.showFooter !== false && (

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import type { SiteConfig, AuthenticationConfig } from "@/types/config" import type { SiteConfig, AuthenticationConfig } from "@/types/config"
import { SochboardLogo } from "@/components/ui/SochboardLogo"
interface DashboardHeaderProps { interface DashboardHeaderProps {
site: SiteConfig site: SiteConfig
@ -9,9 +10,11 @@ interface DashboardHeaderProps {
authentication?: AuthenticationConfig authentication?: AuthenticationConfig
isAuthenticated?: boolean isAuthenticated?: boolean
username?: string | null username?: string | null
accent?: string
version?: string
} }
export function DashboardHeader({ site, initialTheme, authentication, isAuthenticated = false, username = null }: DashboardHeaderProps) { export function DashboardHeader({ site, initialTheme, authentication, isAuthenticated = false, username = null, accent, version }: DashboardHeaderProps) {
const [isLight, setIsLight] = useState(initialTheme === "light") const [isLight, setIsLight] = useState(initialTheme === "light")
useEffect(() => { useEffect(() => {
@ -33,12 +36,7 @@ export function DashboardHeader({ site, initialTheme, authentication, isAuthenti
className="size-12 rounded-xl object-cover" className="size-12 rounded-xl object-cover"
/> />
) : ( ) : (
<div <SochboardLogo size={48} accent={accent} />
className="flex size-12 items-center justify-center rounded-xl bg-gradient-to-br from-[#8b5cf6] to-[#6d28d9] text-lg font-bold text-white shadow-lg shadow-[#8b5cf6]/20"
aria-hidden="true"
>
S
</div>
)} )}
<div> <div>
<h1 className="text-xl font-bold text-[var(--text-primary)]"> <h1 className="text-xl font-bold text-[var(--text-primary)]">
@ -47,6 +45,9 @@ export function DashboardHeader({ site, initialTheme, authentication, isAuthenti
<p className="mt-0.5 text-sm text-[var(--text-secondary)]"> <p className="mt-0.5 text-sm text-[var(--text-secondary)]">
{site.title} {site.title}
</p> </p>
{version && (
<p className="text-xs text-[var(--text-secondary)]">v{version}</p>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -106,7 +107,7 @@ export function DashboardHeader({ site, initialTheme, authentication, isAuthenti
</svg> </svg>
)} )}
</button> </button>
{isAuthenticated && authentication && ( {isAuthenticated && authentication ? (
<a <a
href={authentication.logout_url} href={authentication.logout_url}
className="flex size-10 items-center justify-center rounded-lg border transition-colors hover:opacity-80 active:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)] sm:size-9" className="flex size-10 items-center justify-center rounded-lg border transition-colors hover:opacity-80 active:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)] sm:size-9"
@ -134,6 +135,37 @@ export function DashboardHeader({ site, initialTheme, authentication, isAuthenti
<line x1="21" x2="9" y1="12" y2="12" /> <line x1="21" x2="9" y1="12" y2="12" />
</svg> </svg>
</a> </a>
) : (
!isAuthenticated &&
authentication?.login_url && (
<a
href={authentication.login_url}
className="flex size-10 items-center justify-center rounded-lg border transition-colors hover:opacity-80 active:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)] sm:size-9"
style={{
backgroundColor: "rgba(128,128,128,0.15)",
borderColor: "rgba(128,128,128,0.25)",
color: "var(--text-tertiary)",
}}
aria-label="Connexion"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
<polyline points="10 17 15 12 10 7" />
<line x1="15" x2="3" y1="12" y2="12" />
</svg>
</a>
)
)} )}
</div> </div>
</div> </div>

View file

@ -1,15 +1,16 @@
import { ServiceCard } from "./ServiceCard" import { ServiceCard } from "./ServiceCard"
import { isVisible } from "@/lib/visibility" import { isVisible } from "@/lib/visibility"
import type { SectionConfig } from "@/types/config" import type { SectionConfig, IconSize } from "@/types/config"
interface DashboardSectionProps { interface DashboardSectionProps {
section: SectionConfig section: SectionConfig
sectionIndex?: number sectionIndex?: number
currentUser?: string | null currentUser?: string | null
userGroups?: string[] userGroups?: string[]
iconSize?: IconSize
} }
export function DashboardSection({ section, sectionIndex = 0, currentUser = null, userGroups = [] }: DashboardSectionProps) { export function DashboardSection({ section, sectionIndex = 0, currentUser = null, userGroups = [], iconSize = "medium" }: DashboardSectionProps) {
if (!isVisible(section, currentUser, userGroups)) return null if (!isVisible(section, currentUser, userGroups)) return null
const visibleLinks = section.links.filter((link) => isVisible(link, currentUser, userGroups)) const visibleLinks = section.links.filter((link) => isVisible(link, currentUser, userGroups))
@ -32,13 +33,14 @@ export function DashboardSection({ section, sectionIndex = 0, currentUser = null
<p className="mt-1 text-sm text-[var(--text-secondary)]">{section.description}</p> <p className="mt-1 text-sm text-[var(--text-secondary)]">{section.description}</p>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 sm:gap-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 sm:gap-4">
{visibleLinks.map((link, index) => ( {visibleLinks.map((link, index) => (
<ServiceCard <ServiceCard
key={link.id} key={link.id}
link={link} link={link}
index={index} index={index}
layout={section.cardLayout} layout={section.cardLayout}
iconSize={iconSize}
/> />
))} ))}
</div> </div>

View file

@ -1,18 +1,26 @@
import { IconRenderer } from "./IconRenderer" import { IconRenderer } from "./IconRenderer"
import type { LinkConfig } from "@/types/config" import type { LinkConfig, IconSize } from "@/types/config"
interface ServiceCardProps { interface ServiceCardProps {
link: LinkConfig link: LinkConfig
index?: number index?: number
layout?: "horizontal" | "vertical" layout?: "horizontal" | "vertical"
iconSize?: IconSize
} }
export function ServiceCard({ link, index = 0, layout = "horizontal" }: ServiceCardProps) { const sizeMap: Record<IconSize, { hCont: number; hImg: string; hIcon: number; vCont: number; vImg: string; vIcon: number }> = {
small: { hCont: 10, hImg: "size-5", hIcon: 18, vCont: 12, vImg: "size-6", vIcon: 22 },
medium: { hCont: 12, hImg: "size-7", hIcon: 24, vCont: 14, vImg: "size-8", vIcon: 30 },
large: { hCont: 12, hImg: "size-8", hIcon: 28, vCont: 14, vImg: "size-9", vIcon: 34 },
}
export function ServiceCard({ link, index = 0, layout = "horizontal", iconSize = "medium" }: ServiceCardProps) {
const linkProps = link.openInNewTab const linkProps = link.openInNewTab
? { target: "_blank", rel: "noopener noreferrer" } ? { target: "_blank", rel: "noopener noreferrer" }
: {} : {}
const isHorizontal = layout === "horizontal" const isHorizontal = layout === "horizontal"
const s = sizeMap[iconSize]
return ( return (
<a <a
@ -24,16 +32,18 @@ export function ServiceCard({ link, index = 0, layout = "horizontal" }: ServiceC
{isHorizontal ? ( {isHorizontal ? (
<> <>
<div <div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg" className="flex shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ style={{
width: s.hCont * 4,
height: s.hCont * 4,
backgroundColor: `${link.color || "#8b5cf6"}20`, backgroundColor: `${link.color || "#8b5cf6"}20`,
color: link.color || "#8b5cf6", color: link.color || "#8b5cf6",
}} }}
> >
{link.iconUrl ? ( {link.iconUrl ? (
<img src={link.iconUrl} alt="" className="size-5 object-contain" /> <img src={link.iconUrl} alt="" className={`${s.hImg} object-contain`} />
) : ( ) : (
<IconRenderer name={link.icon} size={18} /> <IconRenderer name={link.icon} size={s.hIcon} />
)} )}
</div> </div>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
@ -73,16 +83,18 @@ export function ServiceCard({ link, index = 0, layout = "horizontal" }: ServiceC
<> <>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div <div
className="flex size-12 items-center justify-center overflow-hidden rounded-xl" className="flex items-center justify-center overflow-hidden rounded-xl"
style={{ style={{
width: s.vCont * 4,
height: s.vCont * 4,
backgroundColor: `${link.color || "#8b5cf6"}20`, backgroundColor: `${link.color || "#8b5cf6"}20`,
color: link.color || "#8b5cf6", color: link.color || "#8b5cf6",
}} }}
> >
{link.iconUrl ? ( {link.iconUrl ? (
<img src={link.iconUrl} alt="" className="size-6 object-contain" /> <img src={link.iconUrl} alt="" className={`${s.vImg} object-contain`} />
) : ( ) : (
<IconRenderer name={link.icon} size={22} /> <IconRenderer name={link.icon} size={s.vIcon} />
)} )}
</div> </div>
{link.openInNewTab && ( {link.openInNewTab && (

View file

@ -0,0 +1,69 @@
interface SochboardLogoProps {
size?: number
accent?: string
className?: string
}
export function SochboardLogo({ size = 48, accent = "#f59e0b", className }: SochboardLogoProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
<rect width="48" height="48" rx="12" fill={accent} />
<rect x="4" y="4" width="40" height="40" rx="10" fill={`${accent}20`} />
<path
d="M24 12C21.5 12 19 13.5 19 16.5C19 18 20 19 21 19.5C19.5 20 18 21.5 18 24C18 26.5 20 28 22 28C20.5 28.5 19 30 19 32.5C19 35.5 21.5 37 24 37C26.5 37 29 35.5 29 32.5C29 31 28 30 27 29.5C28.5 29 30 27.5 30 25C30 22.5 28 21 26 21C27.5 20.5 29 19 29 16.5C29 13.5 26.5 12 24 12Z"
fill="white"
fillOpacity="0.95"
/>
<path
d="M12 12H36"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeOpacity="0.2"
/>
<path
d="M12 36H36"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeOpacity="0.2"
/>
<path
d="M12 24H36"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeOpacity="0.2"
/>
<path
d="M24 12V36"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeOpacity="0.2"
/>
</svg>
)
}
export function SochboardLogoDataUri(accent: string = "#f59e0b"): string {
const hex = accent.replace("#", "%23")
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="${hex}"/>
<rect x="4" y="4" width="40" height="40" rx="10" fill="${hex}20"/>
<path d="M24 12C21.5 12 19 13.5 19 16.5C19 18 20 19 21 19.5C19.5 20 18 21.5 18 24C18 26.5 20 28 22 28C20.5 28.5 19 30 19 32.5C19 35.5 21.5 37 24 37C26.5 37 29 35.5 29 32.5C29 31 28 30 27 29.5C28.5 29 30 27.5 30 25C30 22.5 28 21 26 21C27.5 20.5 29 19 29 16.5C29 13.5 26.5 12 24 12Z" fill="white" fill-opacity="0.95"/>
<path d="M12 12H36" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.2"/>
<path d="M12 36H36" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.2"/>
<path d="M12 24H36" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.2"/>
<path d="M24 12V36" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.2"/>
</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}

View file

@ -32,6 +32,7 @@ export const themeSchema = z.object({
.regex(hexColorRegex, "Invalid accent color") .regex(hexColorRegex, "Invalid accent color")
.default("#8b5cf6"), .default("#8b5cf6"),
background: z.enum(["gradient", "solid"]).default("gradient"), background: z.enum(["gradient", "solid"]).default("gradient"),
icon_size: z.enum(["small", "medium", "large"]).optional().default("medium"),
}) })
export const siteSchema = z.object({ export const siteSchema = z.object({
@ -45,6 +46,7 @@ export const siteSchema = z.object({
export const authenticationSchema = z.object({ export const authenticationSchema = z.object({
logout_url: z.string().url("Invalid logout URL").min(1, "Logout URL is required"), logout_url: z.string().url("Invalid logout URL").min(1, "Logout URL is required"),
login_url: z.string().url("Invalid login URL").optional(),
header_format: z.string().optional().default("Remote-User"), header_format: z.string().optional().default("Remote-User"),
username: z.string().optional(), username: z.string().optional(),
header_user: z.string().optional(), header_user: z.string().optional(),
@ -57,6 +59,7 @@ export const dashboardConfigSchema = z.object({
mode: "dark", mode: "dark",
accent: "#8b5cf6", accent: "#8b5cf6",
background: "gradient", background: "gradient",
icon_size: "medium",
}), }),
authentication: authenticationSchema.optional(), authentication: authenticationSchema.optional(),
sections: z.array(sectionSchema).default([]), sections: z.array(sectionSchema).default([]),

3
src/lib/version.ts Normal file
View file

@ -0,0 +1,3 @@
import pkg from "../../package.json"
export const VERSION = pkg.version

View file

@ -7,10 +7,13 @@ export interface SiteConfig {
showFooter?: boolean showFooter?: boolean
} }
export type IconSize = "small" | "medium" | "large"
export interface ThemeConfig { export interface ThemeConfig {
mode: "dark" | "light" mode: "dark" | "light"
accent: string accent: string
background: "gradient" | "solid" background: "gradient" | "solid"
icon_size?: IconSize
} }
export interface LinkConfig { export interface LinkConfig {
@ -38,6 +41,7 @@ export interface SectionConfig {
export interface AuthenticationConfig { export interface AuthenticationConfig {
logout_url: string logout_url: string
login_url?: string
header_format?: string header_format?: string
username?: string username?: string
header_user?: string header_user?: string