diff --git a/README.md b/README.md
index 296455b..072a765 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,10 @@ A self-hosted homepage portal for your homelab services.
## Features
- Drag-and-drop sections and widgets
-- Visual icon picker (5000+ icons)
-- Custom icon uploads
-- Labels with color coding
+- icon picker (5000+ icons)
+- Custom icon uploads or download from known sources
+- Labels
+- Health check
- Theme customization (colors, card styles)
- Dark/light mode
- Edit mode with right-click context menus
@@ -31,6 +32,10 @@ services:
- /tmp
```
+> [!WARNING]
+> Labbit has no built-in authentication. Run it on your LAN, over a VPN, or behind an auth proxy (Authelia, Authentik, Tailscale).
+
+
## Development
```bash
diff --git a/app/components/board/BoardSection.vue b/app/components/board/BoardSection.vue
index b51856f..80581f4 100644
--- a/app/components/board/BoardSection.vue
+++ b/app/components/board/BoardSection.vue
@@ -43,7 +43,7 @@ function onDragChange() {
}
function toggleCollapse() {
- if (props.section.collapsible === false) return
+ if (!props.section.collapsible) return
store.updateSection(props.section.id, { collapsed: !props.section.collapsed })
markDirty()
}
@@ -63,17 +63,17 @@ function toggleCollapse() {
(() => {
+ const variant = (props.section.defaults?.cardVariant || 'outline') as string
if (variant === 'accent' || variant === 'ghost') return 'outline'
- return variant
+ if (variant === 'soft') return 'subtle'
+ if (variant === 'subtle' || variant === 'solid') return variant
+ return 'outline'
})
const cardColor = computed(() => {
return props.section.defaults?.cardColor || undefined
})
+const cardUi = computed(() => {
+ if (isGhost.value) {
+ return { root: 'rounded-lg bg-transparent ring-0 divide-y-0', body: 'p-4' }
+ }
+ return { body: 'p-4' }
+})
+
const resolvedPlugins = computed(() => {
return useResolvedPlugins(props.section, props.widget)
})
@@ -41,11 +53,11 @@ function pluginsAt(position: string) {
:color="cardColor"
class="relative h-full transition-all duration-200"
:class="{
- 'ring-2 ring-primary/30 hover:ring-primary/50 select-none': isEditing,
+ 'hover:ring-2 hover:ring-primary/40 select-none cursor-grab': isEditing,
'ring-2 ring-primary/30': isAccent && !isEditing,
'hover:ring-1 hover:ring-accented cursor-pointer': isLinkable && !isEditing
}"
- :ui="{ body: 'p-4' }"
+ :ui="cardUi"
>
, intervalMs: Ref) {
+ const result = ref({
+ online: null,
+ status: 0,
+ latency: 0,
+ loading: true
+ })
+
+ let timer: number | null = null
+
+ async function check() {
+ if (!url.value) return
+
+ try {
+ const data = await $fetch<{ online: boolean, status: number, latency: number }>(
+ '/api/health/check',
+ { params: { url: url.value } }
+ )
+ result.value = { ...data, loading: false }
+ } catch {
+ result.value = { online: false, status: 0, latency: 0, loading: false }
+ }
+ }
+
+ function start() {
+ if (!import.meta.client) return
+ stop()
+ check()
+ if (intervalMs.value > 0) {
+ timer = window.setInterval(check, intervalMs.value)
+ }
+ }
+
+ function stop() {
+ if (timer) {
+ window.clearInterval(timer)
+ timer = null
+ }
+ }
+
+ watch([url, intervalMs], () => {
+ if (url.value) {
+ start()
+ } else {
+ stop()
+ }
+ }, { immediate: true })
+
+ onBeforeUnmount(() => stop())
+
+ return { result: readonly(result) }
+}
diff --git a/app/plugins/health-check.ts b/app/plugins/health-check.ts
new file mode 100644
index 0000000..1119dc2
--- /dev/null
+++ b/app/plugins/health-check.ts
@@ -0,0 +1,19 @@
+import HealthDot from '~/components/plugins/HealthDot.vue'
+import HealthDotSettings from '~/components/plugins/HealthDotSettings.vue'
+
+export default defineNuxtPlugin(() => {
+ const { register } = usePluginRegistry()
+
+ register({
+ id: 'health-check',
+ label: 'Health Check',
+ icon: 'i-lucide-activity',
+ defaultPosition: 'top-left',
+ defaultConfig: {
+ intervalSeconds: 30
+ },
+ compatibleWith: ['service-link'],
+ component: HealthDot,
+ settingsComponent: HealthDotSettings
+ })
+})
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 985aba2..ddba5a3 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -22,15 +22,6 @@ export default defineNuxtConfig({
compatibilityDate: '2025-01-15',
- nitro: {
- storage: {
- boards: {
- driver: 'fs',
- base: './data/boards'
- }
- }
- },
-
eslint: {
config: {
stylistic: {
diff --git a/package.json b/package.json
index 3fedb51..e6004f3 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@nuxt/ui": "^4.4.0",
"@pinia/nuxt": "0.11.3",
"@vueuse/nuxt": "14.2.1",
+ "ipaddr.js": "^2.4.0",
"nanoid": "^5.1.6",
"nuxt": "^4.3.1",
"tailwindcss": "^4.1.18",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb7478b..40d4e55 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
'@vueuse/nuxt':
specifier: 14.2.1
version: 14.2.1(magicast@0.5.2)(nuxt@4.3.1(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.28)(cac@6.7.14)(db0@0.3.4)(eslint@10.0.0(jiti@2.6.1))(ioredis@5.9.3)(lightningcss@1.31.1)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3))
+ ipaddr.js:
+ specifier: ^2.4.0
+ version: 2.4.0
nanoid:
specifier: ^5.1.6
version: 5.1.6
@@ -3353,6 +3356,10 @@ packages:
resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==}
engines: {node: '>=12.22.0'}
+ ipaddr.js@2.4.0:
+ resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
+ engines: {node: '>= 10'}
+
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
@@ -8729,6 +8736,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ ipaddr.js@2.4.0: {}
+
iron-webcrypto@1.2.1: {}
is-builtin-module@5.0.0:
diff --git a/server/api/boards/[id].get.ts b/server/api/boards/[id].get.ts
index ac8dadb..13292eb 100644
--- a/server/api/boards/[id].get.ts
+++ b/server/api/boards/[id].get.ts
@@ -1,14 +1,10 @@
-import type { Board } from '~~/shared/types'
-
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id || !VALID_BOARD_ID.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid board ID' })
}
- const storage = useStorage('boards')
- const board = await storage.getItem(id)
-
+ const board = await readBoard(id)
if (!board) {
throw createError({ statusCode: 404, statusMessage: 'Board not found' })
}
diff --git a/server/api/boards/[id].put.ts b/server/api/boards/[id].put.ts
index 4a11157..0df3134 100644
--- a/server/api/boards/[id].put.ts
+++ b/server/api/boards/[id].put.ts
@@ -7,9 +7,8 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody(event)
- const storage = useStorage('boards')
- const existing = await storage.getItem(id)
+ const existing = await readBoard(id)
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Board not found' })
}
@@ -17,6 +16,6 @@ export default defineEventHandler(async (event) => {
body.id = id
body.createdAt = existing.createdAt
body.updatedAt = new Date().toISOString()
- await storage.setItem(id, body)
+ await writeBoard(body)
return body
})
diff --git a/server/api/boards/index.get.ts b/server/api/boards/index.get.ts
index 87a2468..3428bac 100644
--- a/server/api/boards/index.get.ts
+++ b/server/api/boards/index.get.ts
@@ -1,14 +1,4 @@
-import type { Board } from '~~/shared/types'
-
export default defineEventHandler(async () => {
- const storage = useStorage('boards')
- const keys = await storage.getKeys()
- const boards: Board[] = []
-
- for (const key of keys) {
- const board = await storage.getItem(key)
- if (board) boards.push(board)
- }
-
+ const boards = await listBoards()
return boards.sort((a, b) => a.title.localeCompare(b.title))
})
diff --git a/server/api/boards/index.post.ts b/server/api/boards/index.post.ts
index a55996d..0f700b8 100644
--- a/server/api/boards/index.post.ts
+++ b/server/api/boards/index.post.ts
@@ -10,9 +10,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Board must have a title' })
}
- const storage = useStorage('boards')
-
- const existing = await storage.getItem(body.id)
+ const existing = await readBoard(body.id)
if (existing) {
throw createError({ statusCode: 409, statusMessage: 'Board already exists' })
}
@@ -21,6 +19,6 @@ export default defineEventHandler(async (event) => {
body.createdAt = now
body.updatedAt = now
- await storage.setItem(body.id, body)
+ await writeBoard(body)
return body
})
diff --git a/server/api/health/check.get.ts b/server/api/health/check.get.ts
new file mode 100644
index 0000000..a6f8035
--- /dev/null
+++ b/server/api/health/check.get.ts
@@ -0,0 +1,42 @@
+export default defineEventHandler(async (event) => {
+ const query = getQuery(event)
+ const url = query.url as string
+
+ if (!url) {
+ throw createError({ statusCode: 400, statusMessage: 'Missing url parameter' })
+ }
+
+ let parsedUrl: URL
+ try {
+ parsedUrl = new URL(url)
+ } catch {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid URL' })
+ }
+
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
+ throw createError({ statusCode: 400, statusMessage: 'Only HTTP/HTTPS URLs are supported' })
+ }
+
+ const start = Date.now()
+
+ try {
+ const response = await $fetch.raw(url, {
+ method: 'GET',
+ timeout: 10_000,
+ redirect: 'follow',
+ ignoreResponseError: true
+ })
+
+ return {
+ status: response.status,
+ latency: Date.now() - start,
+ online: response.status >= 200 && response.status < 400
+ }
+ } catch {
+ return {
+ status: 0,
+ latency: Date.now() - start,
+ online: false
+ }
+ }
+})
diff --git a/server/api/icons/custom/download.post.ts b/server/api/icons/custom/download.post.ts
new file mode 100644
index 0000000..72e3a05
--- /dev/null
+++ b/server/api/icons/custom/download.post.ts
@@ -0,0 +1,194 @@
+import { writeFile, readdir, mkdir } from 'node:fs/promises'
+import { join, extname } from 'node:path'
+import { Buffer } from 'node:buffer'
+import { isIP } from 'node:net'
+import { lookup } from 'node:dns/promises'
+import ipaddr from 'ipaddr.js'
+
+const MAX_REDIRECTS = 3
+const FETCH_TIMEOUT_MS = 10000
+
+const MIME_TO_EXT: Record = {
+ 'image/svg+xml': '.svg',
+ 'image/png': '.png',
+ 'image/jpeg': '.jpg',
+ 'image/webp': '.webp'
+}
+
+const BLOCKED_RANGES = new Set([
+ 'private',
+ 'loopback',
+ 'linkLocal',
+ 'uniqueLocal',
+ 'multicast',
+ 'unspecified',
+ 'reserved',
+ 'carrierGradeNat',
+ 'broadcast'
+])
+
+function isPrivateIP(ip: string): boolean {
+ let parsed
+ try {
+ parsed = ipaddr.parse(ip)
+ } catch {
+ return true
+ }
+
+ if (parsed.kind() === 'ipv6' && (parsed as ipaddr.IPv6).isIPv4MappedAddress()) {
+ parsed = (parsed as ipaddr.IPv6).toIPv4Address()
+ }
+
+ const range = parsed.range()
+ return BLOCKED_RANGES.has(range)
+}
+
+async function assertSafeHost(host: string): Promise {
+ let address = host
+ if (!isIP(host)) {
+ try {
+ const resolved = await lookup(host)
+ address = resolved.address
+ } catch {
+ throw createError({ statusCode: 400, statusMessage: 'Could not resolve hostname' })
+ }
+ }
+ if (isPrivateIP(address)) {
+ throw createError({ statusCode: 400, statusMessage: 'Requests to private/internal addresses are not allowed' })
+ }
+}
+
+async function fetchWithSafeRedirects(initialUrl: string): Promise {
+ let currentUrl = initialUrl
+
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
+ const parsed = new URL(currentUrl)
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ throw createError({ statusCode: 400, statusMessage: 'Only HTTP/HTTPS URLs are supported' })
+ }
+ await assertSafeHost(parsed.hostname)
+
+ let response: Response
+ try {
+ response = await fetch(currentUrl, {
+ headers: { 'User-Agent': 'Labbit/1.0' },
+ redirect: 'manual',
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
+ })
+ } catch {
+ throw createError({ statusCode: 502, statusMessage: 'Failed to fetch icon from URL' })
+ }
+
+ if (response.status >= 300 && response.status < 400) {
+ const location = response.headers.get('location')
+ if (!location) {
+ throw createError({ statusCode: 502, statusMessage: 'Redirect with no Location header' })
+ }
+ if (hop === MAX_REDIRECTS) {
+ throw createError({ statusCode: 502, statusMessage: 'Too many redirects' })
+ }
+ currentUrl = new URL(location, currentUrl).toString()
+ continue
+ }
+
+ return response
+ }
+
+ throw createError({ statusCode: 502, statusMessage: 'Too many redirects' })
+}
+
+function filenameFromUrl(url: string, contentType: string): string {
+ const pathname = new URL(url).pathname
+ const basename = pathname.split('/').pop() || 'icon'
+ const ext = extname(basename).toLowerCase()
+
+ if (ext && ALLOWED_ICON_EXT.includes(ext)) {
+ return sanitizeIconFilename(basename)
+ }
+
+ const mappedExt = MIME_TO_EXT[contentType]
+ if (mappedExt) {
+ const nameWithoutExt = basename.replace(/\.[^.]+$/, '') || 'icon'
+ return sanitizeIconFilename(`${nameWithoutExt}${mappedExt}`)
+ }
+
+ return ''
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody<{ url: string }>(event)
+
+ if (!body.url || typeof body.url !== 'string') {
+ throw createError({ statusCode: 400, statusMessage: 'URL is required' })
+ }
+
+ try {
+ new URL(body.url)
+ } catch {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid URL' })
+ }
+
+ await mkdir(ICONS_DIR, { recursive: true })
+
+ const existing = await readdir(ICONS_DIR)
+ if (existing.length >= MAX_ICON_COUNT) {
+ throw createError({ statusCode: 400, statusMessage: `Maximum ${MAX_ICON_COUNT} custom icons allowed` })
+ }
+
+ const response = await fetchWithSafeRedirects(body.url)
+
+ if (!response.ok) {
+ throw createError({ statusCode: 502, statusMessage: `Remote server returned ${response.status}` })
+ }
+
+ const contentLength = response.headers.get('content-length')
+ if (contentLength && parseInt(contentLength, 10) > MAX_ICON_SIZE) {
+ throw createError({ statusCode: 400, statusMessage: 'Remote icon exceeds 512KB limit' })
+ }
+
+ const contentType = (response.headers.get('content-type') || '').split(';')[0]?.trim() || ''
+ if (!Object.keys(MIME_TO_EXT).includes(contentType)) {
+ throw createError({ statusCode: 400, statusMessage: `Unsupported content type: ${contentType}` })
+ }
+
+ const arrayBuffer = await response.arrayBuffer()
+ let data = Buffer.from(arrayBuffer)
+
+ if (data.length > MAX_ICON_SIZE) {
+ throw createError({ statusCode: 400, statusMessage: 'Downloaded icon exceeds 512KB limit' })
+ }
+
+ const safeName = filenameFromUrl(body.url, contentType)
+ if (!safeName) {
+ throw createError({ statusCode: 400, statusMessage: 'Could not determine a valid filename from URL' })
+ }
+
+ const ext = extname(safeName).toLowerCase()
+ if (ext === '.svg') {
+ const content = data.toString('utf-8')
+ if (!isValidSvg(content)) {
+ throw createError({ statusCode: 400, statusMessage: 'Invalid SVG file' })
+ }
+ data = Buffer.from(sanitizeSvg(content), 'utf-8')
+ }
+
+ let finalName = safeName
+ if (existing.includes(finalName)) {
+ const base = finalName.slice(0, -ext.length)
+ let counter = 1
+ while (existing.includes(`${base}-${counter}${ext}`)) counter++
+ finalName = `${base}-${counter}${ext}`
+ }
+
+ try {
+ await writeFile(join(ICONS_DIR, finalName), data, { flag: 'wx' })
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
+ throw createError({ statusCode: 409, statusMessage: 'Icon file already exists' })
+ }
+ throw err
+ }
+
+ setResponseStatus(event, 201)
+ return { filename: finalName, url: `/api/icons/custom/file/${finalName}` }
+})
diff --git a/server/api/icons/custom/index.post.ts b/server/api/icons/custom/index.post.ts
index 5535b1d..3de18e1 100644
--- a/server/api/icons/custom/index.post.ts
+++ b/server/api/icons/custom/index.post.ts
@@ -2,21 +2,8 @@ import { writeFile, readdir, mkdir } from 'node:fs/promises'
import { join, extname } from 'node:path'
import { Buffer } from 'node:buffer'
-const MAX_FILE_SIZE = 512 * 1024
-const MAX_FILE_COUNT = 200
const ALLOWED_TYPES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/webp']
-function sanitizeFilename(raw: string): string {
- const ext = extname(raw).toLowerCase()
- const base = raw
- .slice(0, -ext.length)
- .toLowerCase()
- .replace(/[^a-z0-9-]/g, '-')
- .replace(/-+/g, '-')
- .replace(/^-|-$/g, '')
- return base ? `${base}${ext}` : ''
-}
-
export default defineEventHandler(async (event) => {
const formData = await readMultipartFormData(event)
if (!formData || formData.length === 0) {
@@ -26,8 +13,8 @@ export default defineEventHandler(async (event) => {
await mkdir(ICONS_DIR, { recursive: true })
const existing = await readdir(ICONS_DIR)
- if (existing.length >= MAX_FILE_COUNT) {
- throw createError({ statusCode: 400, statusMessage: `Maximum ${MAX_FILE_COUNT} custom icons allowed` })
+ if (existing.length >= MAX_ICON_COUNT) {
+ throw createError({ statusCode: 400, statusMessage: `Maximum ${MAX_ICON_COUNT} custom icons allowed` })
}
const file = formData[0]
@@ -35,7 +22,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Invalid file' })
}
- if (file.data.length > MAX_FILE_SIZE) {
+ if (file.data.length > MAX_ICON_SIZE) {
throw createError({ statusCode: 400, statusMessage: 'File too large (max 512KB)' })
}
@@ -50,7 +37,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Content type header required' })
}
- const safeName = sanitizeFilename(file.filename)
+ const safeName = sanitizeIconFilename(file.filename)
if (!safeName) {
throw createError({ statusCode: 400, statusMessage: 'Invalid filename' })
}
diff --git a/server/plugins/seed.ts b/server/plugins/seed.ts
index d013537..64b8a65 100644
--- a/server/plugins/seed.ts
+++ b/server/plugins/seed.ts
@@ -40,7 +40,7 @@ const DEFAULT_BOARD: Board = {
}
],
defaults: {
- cardVariant: 'soft'
+ cardVariant: 'subtle'
}
},
{
@@ -194,12 +194,10 @@ const DEFAULT_BOARD: Board = {
}
export default defineNitroPlugin(async () => {
- const storage = useStorage('boards')
- const existing = await storage.getItem('default')
+ const boards = await listBoards()
+ if (boards.length > 0) return
- if (!existing) {
- console.log('[labbit] No boards found, creating default board...')
- await storage.setItem('default', DEFAULT_BOARD)
- console.log('[labbit] Default board created.')
- }
+ console.log('[labbit] No boards found, creating default board...')
+ await writeBoard(DEFAULT_BOARD)
+ console.log('[labbit] Default board created.')
})
diff --git a/server/utils/board-storage.ts b/server/utils/board-storage.ts
new file mode 100644
index 0000000..6c063e6
--- /dev/null
+++ b/server/utils/board-storage.ts
@@ -0,0 +1,42 @@
+import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import type { Board } from '~~/shared/types'
+
+function boardPath(id: string): string {
+ return join(BOARDS_DIR, `${id}.json`)
+}
+
+export async function readBoard(id: string): Promise {
+ try {
+ const raw = await readFile(boardPath(id), 'utf-8')
+ return JSON.parse(raw)
+ } catch {
+ return null
+ }
+}
+
+export async function writeBoard(board: Board): Promise {
+ await mkdir(BOARDS_DIR, { recursive: true })
+ await writeFile(boardPath(board.id), JSON.stringify(board, null, 2), 'utf-8')
+}
+
+export async function listBoards(): Promise {
+ let files: string[]
+ try {
+ files = await readdir(BOARDS_DIR)
+ } catch {
+ return []
+ }
+
+ const boards: Board[] = []
+ for (const file of files) {
+ if (!file.endsWith('.json')) continue
+ try {
+ const raw = await readFile(join(BOARDS_DIR, file), 'utf-8')
+ boards.push(JSON.parse(raw))
+ } catch {
+ // skip malformed files
+ }
+ }
+ return boards
+}
diff --git a/server/utils/constants.ts b/server/utils/constants.ts
index 99d620f..d3e741d 100644
--- a/server/utils/constants.ts
+++ b/server/utils/constants.ts
@@ -1,6 +1,20 @@
-import { join } from 'node:path'
+import { join, extname } from 'node:path'
export const VALID_BOARD_ID = /^[a-z0-9][a-z0-9_-]*$/
+export const BOARDS_DIR = join(process.cwd(), 'data', 'boards')
export const ICONS_DIR = join(process.cwd(), 'data', 'icons')
export const SAFE_ICON_FILENAME = /^[a-z0-9][a-z0-9_-]*\.[a-z]+$/
export const ALLOWED_ICON_EXT = ['.svg', '.png', '.jpg', '.jpeg', '.webp']
+export const MAX_ICON_SIZE = 512 * 1024
+export const MAX_ICON_COUNT = 200
+
+export function sanitizeIconFilename(raw: string): string {
+ const ext = extname(raw).toLowerCase()
+ const base = raw
+ .slice(0, -ext.length)
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+ return base ? `${base}${ext}` : ''
+}
diff --git a/server/utils/icon-index.ts b/server/utils/icon-index.ts
index da801ef..ff63296 100644
--- a/server/utils/icon-index.ts
+++ b/server/utils/icon-index.ts
@@ -1,5 +1,5 @@
-import { readFileSync } from 'node:fs'
-import { createRequire } from 'node:module'
+import lucideData from '@iconify-json/lucide/icons.json'
+import simpleIconsData from '@iconify-json/simple-icons/icons.json'
interface IconCollection {
id: string
@@ -8,33 +8,22 @@ interface IconCollection {
iconNames: string[]
}
-let collections: IconCollection[] | null = null
-
-function loadCollections(): IconCollection[] {
- const require = createRequire(import.meta.url)
-
- const specs = [
- { id: 'lucide', name: 'Lucide', pkg: '@iconify-json/lucide/icons.json' },
- { id: 'simple-icons', name: 'Simple Icons', pkg: '@iconify-json/simple-icons/icons.json' }
- ]
-
- return specs.map((spec) => {
- const jsonPath = require.resolve(spec.pkg)
- const data = JSON.parse(readFileSync(jsonPath, 'utf-8'))
- const iconNames = Object.keys(data.icons)
- return {
- id: spec.id,
- name: spec.name,
- prefix: data.prefix,
- iconNames
- }
- })
-}
+const collections: IconCollection[] = [
+ {
+ id: 'lucide',
+ name: 'Lucide',
+ prefix: lucideData.prefix,
+ iconNames: Object.keys(lucideData.icons)
+ },
+ {
+ id: 'simple-icons',
+ name: 'Simple Icons',
+ prefix: simpleIconsData.prefix,
+ iconNames: Object.keys(simpleIconsData.icons)
+ }
+]
export function getCollections(): IconCollection[] {
- if (!collections) {
- collections = loadCollections()
- }
return collections
}
diff --git a/shared/types/index.ts b/shared/types/index.ts
index 9dccc3d..1579353 100644
--- a/shared/types/index.ts
+++ b/shared/types/index.ts
@@ -51,7 +51,7 @@ export interface BoardSection {
showTitle: boolean
widgets: WidgetInstance[]
defaults: {
- cardVariant?: 'outline' | 'accent' | 'soft' | 'subtle' | 'ghost'
+ cardVariant?: 'outline' | 'accent' | 'subtle' | 'ghost'
cardColor?: string
plugins?: Record