;
diff --git a/frontend/src/lib/wailsjs/go/main/App.js b/frontend/src/lib/wailsjs/go/main/App.js
index 228744d..27b31d9 100644
--- a/frontend/src/lib/wailsjs/go/main/App.js
+++ b/frontend/src/lib/wailsjs/go/main/App.js
@@ -2,10 +2,34 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+export function CheckForUpdates() {
+ return window['go']['main']['App']['CheckForUpdates']();
+}
+
export function CheckIsDefaultEMLHandler() {
return window['go']['main']['App']['CheckIsDefaultEMLHandler']();
}
+export function ConvertToUTF8(arg1) {
+ return window['go']['main']['App']['ConvertToUTF8'](arg1);
+}
+
+export function CreateBugReportFolder() {
+ return window['go']['main']['App']['CreateBugReportFolder']();
+}
+
+export function DetectEmailFormat(arg1) {
+ return window['go']['main']['App']['DetectEmailFormat'](arg1);
+}
+
+export function DownloadUpdate() {
+ return window['go']['main']['App']['DownloadUpdate']();
+}
+
+export function ExportSettings(arg1) {
+ return window['go']['main']['App']['ExportSettings'](arg1);
+}
+
export function FrontendLog(arg1, arg2) {
return window['go']['main']['App']['FrontendLog'](arg1, arg2);
}
@@ -14,6 +38,10 @@ export function GetConfig() {
return window['go']['main']['App']['GetConfig']();
}
+export function GetCurrentMailFilePath() {
+ return window['go']['main']['App']['GetCurrentMailFilePath']();
+}
+
export function GetImageViewerData() {
return window['go']['main']['App']['GetImageViewerData']();
}
@@ -30,10 +58,30 @@ export function GetStartupFile() {
return window['go']['main']['App']['GetStartupFile']();
}
+export function GetUpdateStatus() {
+ return window['go']['main']['App']['GetUpdateStatus']();
+}
+
export function GetViewerData() {
return window['go']['main']['App']['GetViewerData']();
}
+export function ImportSettings() {
+ return window['go']['main']['App']['ImportSettings']();
+}
+
+export function InstallUpdate(arg1) {
+ return window['go']['main']['App']['InstallUpdate'](arg1);
+}
+
+export function InstallUpdateSilent() {
+ return window['go']['main']['App']['InstallUpdateSilent']();
+}
+
+export function InstallUpdateSilentFromPath(arg1) {
+ return window['go']['main']['App']['InstallUpdateSilentFromPath'](arg1);
+}
+
export function IsDebuggerRunning() {
return window['go']['main']['App']['IsDebuggerRunning']();
}
@@ -46,6 +94,10 @@ export function OpenEMLWindow(arg1, arg2) {
return window['go']['main']['App']['OpenEMLWindow'](arg1, arg2);
}
+export function OpenFolderInExplorer(arg1) {
+ return window['go']['main']['App']['OpenFolderInExplorer'](arg1);
+}
+
export function OpenImage(arg1, arg2) {
return window['go']['main']['App']['OpenImage'](arg1, arg2);
}
@@ -62,10 +114,18 @@ export function OpenPDFWindow(arg1, arg2) {
return window['go']['main']['App']['OpenPDFWindow'](arg1, arg2);
}
+export function OpenURLInBrowser(arg1) {
+ return window['go']['main']['App']['OpenURLInBrowser'](arg1);
+}
+
export function QuitApp() {
return window['go']['main']['App']['QuitApp']();
}
+export function ReadAuto(arg1) {
+ return window['go']['main']['App']['ReadAuto'](arg1);
+}
+
export function ReadEML(arg1) {
return window['go']['main']['App']['ReadEML'](arg1);
}
@@ -86,6 +146,30 @@ export function SaveConfig(arg1) {
return window['go']['main']['App']['SaveConfig'](arg1);
}
+export function SaveScreenshot() {
+ return window['go']['main']['App']['SaveScreenshot']();
+}
+
+export function SaveScreenshotAs() {
+ return window['go']['main']['App']['SaveScreenshotAs']();
+}
+
+export function SetCurrentMailFilePath(arg1) {
+ return window['go']['main']['App']['SetCurrentMailFilePath'](arg1);
+}
+
+export function SetUpdateCheckerEnabled(arg1) {
+ return window['go']['main']['App']['SetUpdateCheckerEnabled'](arg1);
+}
+
export function ShowOpenFileDialog() {
return window['go']['main']['App']['ShowOpenFileDialog']();
}
+
+export function SubmitBugReport(arg1) {
+ return window['go']['main']['App']['SubmitBugReport'](arg1);
+}
+
+export function TakeScreenshot() {
+ return window['go']['main']['App']['TakeScreenshot']();
+}
diff --git a/frontend/src/lib/wailsjs/go/models.ts b/frontend/src/lib/wailsjs/go/models.ts
index dfe35d7..9389afe 100644
--- a/frontend/src/lib/wailsjs/go/models.ts
+++ b/frontend/src/lib/wailsjs/go/models.ts
@@ -242,6 +242,44 @@ export namespace internal {
export namespace main {
+ export class BugReportInput {
+ name: string;
+ email: string;
+ description: string;
+ screenshotData: string;
+ localStorageData: string;
+ configData: string;
+
+ static createFrom(source: any = {}) {
+ return new BugReportInput(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.name = source["name"];
+ this.email = source["email"];
+ this.description = source["description"];
+ this.screenshotData = source["screenshotData"];
+ this.localStorageData = source["localStorageData"];
+ this.configData = source["configData"];
+ }
+ }
+ export class BugReportResult {
+ folderPath: string;
+ screenshotPath: string;
+ mailFilePath: string;
+
+ static createFrom(source: any = {}) {
+ return new BugReportResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.folderPath = source["folderPath"];
+ this.screenshotPath = source["screenshotPath"];
+ this.mailFilePath = source["mailFilePath"];
+ }
+ }
export class ImageViewerData {
data: string;
filename: string;
@@ -270,6 +308,70 @@ export namespace main {
this.filename = source["filename"];
}
}
+ export class ScreenshotResult {
+ data: string;
+ width: number;
+ height: number;
+ filename: string;
+
+ static createFrom(source: any = {}) {
+ return new ScreenshotResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.data = source["data"];
+ this.width = source["width"];
+ this.height = source["height"];
+ this.filename = source["filename"];
+ }
+ }
+ export class SubmitBugReportResult {
+ zipPath: string;
+ folderPath: string;
+
+ static createFrom(source: any = {}) {
+ return new SubmitBugReportResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.zipPath = source["zipPath"];
+ this.folderPath = source["folderPath"];
+ }
+ }
+ export class UpdateStatus {
+ currentVersion: string;
+ availableVersion: string;
+ updateAvailable: boolean;
+ checking: boolean;
+ downloading: boolean;
+ downloadProgress: number;
+ ready: boolean;
+ installerPath: string;
+ errorMessage: string;
+ releaseNotes?: string;
+ lastCheckTime: string;
+
+ static createFrom(source: any = {}) {
+ return new UpdateStatus(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.currentVersion = source["currentVersion"];
+ this.availableVersion = source["availableVersion"];
+ this.updateAvailable = source["updateAvailable"];
+ this.checking = source["checking"];
+ this.downloading = source["downloading"];
+ this.downloadProgress = source["downloadProgress"];
+ this.ready = source["ready"];
+ this.installerPath = source["installerPath"];
+ this.errorMessage = source["errorMessage"];
+ this.releaseNotes = source["releaseNotes"];
+ this.lastCheckTime = source["lastCheckTime"];
+ }
+ }
export class ViewerData {
imageData?: ImageViewerData;
pdfData?: PDFViewerData;
@@ -717,6 +819,10 @@ export namespace utils {
SDKDecoderReleaseChannel: string;
GUISemver: string;
GUIReleaseChannel: string;
+ Language: string;
+ UpdateCheckEnabled: string;
+ UpdatePath: string;
+ UpdateAutoCheck: string;
static createFrom(source: any = {}) {
return new EMLyConfig(source);
@@ -728,6 +834,10 @@ export namespace utils {
this.SDKDecoderReleaseChannel = source["SDKDecoderReleaseChannel"];
this.GUISemver = source["GUISemver"];
this.GUIReleaseChannel = source["GUIReleaseChannel"];
+ this.Language = source["Language"];
+ this.UpdateCheckEnabled = source["UpdateCheckEnabled"];
+ this.UpdatePath = source["UpdatePath"];
+ this.UpdateAutoCheck = source["UpdateAutoCheck"];
}
}
export class Config {
diff --git a/go.mod b/go.mod
index e1e8842..70b57cd 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.24.4
require (
github.com/jaypipes/ghw v0.21.2
+ github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32
github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/sys v0.40.0
golang.org/x/text v0.22.0
@@ -30,6 +31,8 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
+ github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad // indirect
+ github.com/teamwork/utils v1.0.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
diff --git a/go.sum b/go.sum
index 9c440d3..bb2bed3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,4 @@
+github.com/Strum355/go-difflib v1.1.0/go.mod h1:r1cVg1JkGsTWkaR7At56v7hfuMgiUL8meTLwxFzOmvE=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -47,6 +48,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -65,6 +67,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/teamwork/test v0.0.0-20190410143529-8897d82f8d46/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
+github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad h1:25sEr0awm0ZPancg5W5H5VvN7PWsJloUBpii10a9isw=
+github.com/teamwork/test v0.0.0-20200108114543-02621bae84ad/go.mod h1:TIbx7tx6WHBjQeLRM4eWQZBL7kmBZ7/KI4x4v7Y5YmA=
+github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32 h1:j15wq0XPAY/HR/0+dtwUrIrF2ZTKbk7QIES2p4dAG+k=
+github.com/teamwork/tnef v0.0.0-20200108124832-7deabccfdb32/go.mod h1:v7dFaQrF/4+curx7UTH9rqTkHTgXqghfI3thANW150o=
+github.com/teamwork/utils v1.0.0 h1:30WqhSbZ9nFhaJSx9HH+yFLiQvL64nqAOyyl5IxoYlY=
+github.com/teamwork/utils v1.0.0/go.mod h1:3Fn0qxFeRNpvsg/9T1+btOOOKkd1qG2nPYKKcOmNpcs=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
diff --git a/installer/installer.iss b/installer/installer.iss
index 18a2cdf..348ae58 100644
--- a/installer/installer.iss
+++ b/installer/installer.iss
@@ -1,6 +1,6 @@
#define ApplicationName 'EMLy'
#define ApplicationVersion GetVersionNumbersString('EMLy.exe')
-#define ApplicationVersion '1.5.4_beta'
+#define ApplicationVersion '1.6.0_beta'
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..1a819ac
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,19 @@
+# MySQL
+MYSQL_HOST=mysql
+MYSQL_PORT=3306
+MYSQL_USER=emly
+MYSQL_PASSWORD=change_me_in_production
+MYSQL_DATABASE=emly_bugreports
+MYSQL_ROOT_PASSWORD=change_root_password
+
+# API Keys
+API_KEY=change_me_client_key
+ADMIN_KEY=change_me_admin_key
+
+# Server
+PORT=3000
+DASHBOARD_PORT=3001
+
+# Rate Limiting
+RATE_LIMIT_MAX=5
+RATE_LIMIT_WINDOW_HOURS=24
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..04e7b8b
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env
+dist/
+*.log
+
+# Dashboard
+dashboard/node_modules/
+dashboard/.svelte-kit/
+dashboard/build/
+dashboard/.env
+dashboard/bun.lock
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..1eefc79
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,13 @@
+FROM oven/bun:alpine
+
+WORKDIR /app
+
+COPY package.json bun.lock* ./
+RUN bun install --frozen-lockfile || bun install
+
+COPY tsconfig.json ./
+COPY src/ ./src/
+
+EXPOSE 3000
+
+CMD ["bun", "run", "src/index.ts"]
diff --git a/server/compose-dev.yml b/server/compose-dev.yml
new file mode 100644
index 0000000..f97f940
--- /dev/null
+++ b/server/compose-dev.yml
@@ -0,0 +1,85 @@
+services:
+ mysql:
+ image: mysql:lts
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ volumes:
+ - mysql_data:/var/lib/mysql
+ ports:
+ - "3306:3306"
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ emly:
+ ipv4_address: 172.16.32.2
+
+ api:
+ build: .
+ environment:
+ MYSQL_HOST: mysql
+ MYSQL_PORT: 3306
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ API_KEY: ${API_KEY}
+ ADMIN_KEY: ${ADMIN_KEY}
+ PORT: 3000
+ RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
+ RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
+ volumes:
+ - ./logs/api:/app/logs
+ restart: on-failure
+ depends_on:
+ mysql:
+ condition: service_healthy
+ networks:
+ emly:
+ ipv4_address: 172.16.32.3
+
+ dashboard:
+ build: ./dashboard
+ environment:
+ MYSQL_HOST: mysql
+ MYSQL_PORT: 3306
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ volumes:
+ - ./logs/dashboard:/app/logs
+ depends_on:
+ mysql:
+ condition: service_healthy
+ networks:
+ emly:
+ ipv4_address: 172.16.32.4
+
+ cloudflared:
+ image: cloudflare/cloudflared:latest
+ command: tunnel run
+ environment:
+ TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN_DEV}
+ depends_on:
+ - api
+ - dashboard
+ restart: unless-stopped
+ networks:
+ emly:
+ ipv4_address: 172.16.32.5
+
+volumes:
+ mysql_data:
+
+networks:
+ emly:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.16.32.0/24
+ gateway: 172.16.32.1
diff --git a/server/compose-prod.yml b/server/compose-prod.yml
new file mode 100644
index 0000000..0bff7c9
--- /dev/null
+++ b/server/compose-prod.yml
@@ -0,0 +1,85 @@
+services:
+ mysql:
+ image: mysql:lts
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ volumes:
+ - mysql_data:/var/lib/mysql
+ ports:
+ - "3306:3306"
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ emly:
+ ipv4_address: 172.16.32.2
+
+ api:
+ build: .
+ environment:
+ MYSQL_HOST: mysql
+ MYSQL_PORT: 3306
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ API_KEY: ${API_KEY}
+ ADMIN_KEY: ${ADMIN_KEY}
+ PORT: 3000
+ RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
+ RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
+ volumes:
+ - ./logs/api:/app/logs
+ restart: on-failure
+ depends_on:
+ mysql:
+ condition: service_healthy
+ networks:
+ emly:
+ ipv4_address: 172.16.32.3
+
+ dashboard:
+ build: ./dashboard
+ environment:
+ MYSQL_HOST: mysql
+ MYSQL_PORT: 3306
+ MYSQL_USER: ${MYSQL_USER:-emly}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
+ volumes:
+ - ./logs/dashboard:/app/logs
+ depends_on:
+ mysql:
+ condition: service_healthy
+ networks:
+ emly:
+ ipv4_address: 172.16.32.4
+
+ cloudflared:
+ image: cloudflare/cloudflared:latest
+ command: tunnel run
+ environment:
+ TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
+ depends_on:
+ - api
+ - dashboard
+ restart: unless-stopped
+ networks:
+ emly:
+ ipv4_address: 172.16.32.5
+
+volumes:
+ mysql_data:
+
+networks:
+ emly:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.16.32.0/24
+ gateway: 172.16.32.1
diff --git a/server/dashboard/.env.example b/server/dashboard/.env.example
new file mode 100644
index 0000000..7e790cc
--- /dev/null
+++ b/server/dashboard/.env.example
@@ -0,0 +1,6 @@
+# MySQL Connection
+MYSQL_HOST=localhost
+MYSQL_PORT=3306
+MYSQL_USER=emly
+MYSQL_PASSWORD=change_me_in_production
+MYSQL_DATABASE=emly_bugreports
diff --git a/server/dashboard/.gitignore b/server/dashboard/.gitignore
new file mode 100644
index 0000000..41068e2
--- /dev/null
+++ b/server/dashboard/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.svelte-kit
+build
+.env
+bun.lock
diff --git a/server/dashboard/Dockerfile b/server/dashboard/Dockerfile
new file mode 100644
index 0000000..1e67551
--- /dev/null
+++ b/server/dashboard/Dockerfile
@@ -0,0 +1,9 @@
+FROM oven/bun:alpine
+WORKDIR /app
+COPY package.json bun.lock* ./
+RUN bun install --frozen-lockfile || bun install
+COPY . .
+RUN bun run build
+ENV NODE_ENV=production
+EXPOSE 3000
+CMD ["bun", "build/index.js"]
diff --git a/server/dashboard/components.json b/server/dashboard/components.json
new file mode 100644
index 0000000..e44a4eb
--- /dev/null
+++ b/server/dashboard/components.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://shadcn-svelte.com/schema.json",
+ "tailwind": {
+ "css": "src\\app.css",
+ "baseColor": "neutral"
+ },
+ "aliases": {
+ "components": "$lib/components",
+ "utils": "$lib/utils",
+ "ui": "$lib/components/ui",
+ "hooks": "$lib/hooks",
+ "lib": "$lib"
+ },
+ "typescript": true,
+ "registry": "https://shadcn-svelte.com/registry"
+}
diff --git a/server/dashboard/drizzle.config.ts b/server/dashboard/drizzle.config.ts
new file mode 100644
index 0000000..2c0ea05
--- /dev/null
+++ b/server/dashboard/drizzle.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ schema: './src/lib/schema.ts',
+ dialect: 'mysql',
+ dbCredentials: {
+ host: process.env.MYSQL_HOST || 'localhost',
+ port: Number(process.env.MYSQL_PORT) || 3306,
+ user: process.env.MYSQL_USER || 'emly',
+ password: process.env.MYSQL_PASSWORD || '',
+ database: process.env.MYSQL_DATABASE || 'emly_bugreports'
+ }
+});
diff --git a/server/dashboard/package.json b/server/dashboard/package.json
new file mode 100644
index 0000000..eafdd10
--- /dev/null
+++ b/server/dashboard/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "emly-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev --port 3001",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@internationalized/date": "^3.10.0",
+ "@lucide/svelte": "^0.561.0",
+ "@sveltejs/adapter-node": "^5.5.3",
+ "@sveltejs/kit": "^2.51.0",
+ "@sveltejs/vite-plugin-svelte": "^5.1.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/node": "^25.2.3",
+ "drizzle-kit": "^0.31.9",
+ "svelte": "^5.51.1",
+ "svelte-check": "^4.4.0",
+ "tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.9.3",
+ "vite": "^6.4.1"
+ },
+ "dependencies": {
+ "@lucia-auth/adapter-drizzle": "^1.1.0",
+ "@node-rs/argon2": "^2.0.2",
+ "drizzle-orm": "^0.38.4",
+ "lucia": "^3.2.2",
+ "mysql2": "^3.17.1",
+ "bits-ui": "^2.14.4",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^3.4.0",
+ "tailwind-variants": "^3.2.2",
+ "jszip": "^3.10.1",
+ "lucide-svelte": "^0.469.0"
+ },
+ "type": "module"
+}
diff --git a/server/dashboard/src/app.css b/server/dashboard/src/app.css
new file mode 100644
index 0000000..ac256d3
--- /dev/null
+++ b/server/dashboard/src/app.css
@@ -0,0 +1,121 @@
+@import "tailwindcss";
+
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/server/dashboard/src/app.d.ts b/server/dashboard/src/app.d.ts
new file mode 100644
index 0000000..c0087e0
--- /dev/null
+++ b/server/dashboard/src/app.d.ts
@@ -0,0 +1,10 @@
+declare global {
+ namespace App {
+ interface Locals {
+ user: import('lucia').User | null;
+ session: import('lucia').Session | null;
+ }
+ }
+}
+
+export {};
diff --git a/server/dashboard/src/app.html b/server/dashboard/src/app.html
new file mode 100644
index 0000000..a2e03b2
--- /dev/null
+++ b/server/dashboard/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/server/dashboard/src/hooks.server.ts b/server/dashboard/src/hooks.server.ts
new file mode 100644
index 0000000..0909941
--- /dev/null
+++ b/server/dashboard/src/hooks.server.ts
@@ -0,0 +1,60 @@
+import type { Handle } from '@sveltejs/kit';
+import { lucia } from '$lib/server/auth';
+import { initLogger, Log } from '$lib/server/logger';
+
+// Initialize dashboard logger
+initLogger();
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const ip =
+ event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ event.request.headers.get('x-real-ip') ||
+ event.getClientAddress?.() ||
+ 'unknown';
+ Log('HTTP', `${event.request.method} ${event.url.pathname} from ${ip}`);
+
+ const sessionId = event.cookies.get(lucia.sessionCookieName);
+
+ if (!sessionId) {
+ event.locals.user = null;
+ event.locals.session = null;
+ return resolve(event);
+ }
+
+ const { session, user } = await lucia.validateSession(sessionId);
+
+ if (session && session.fresh) {
+ const sessionCookie = lucia.createSessionCookie(session.id);
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ }
+
+ if (!session) {
+ Log('AUTH', `Invalid session from ip=${ip}`);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ }
+
+ // If user is disabled, invalidate their session and clear cookie
+ if (session && user && !user.enabled) {
+ Log('AUTH', `Disabled user rejected: username=${user.username} ip=${ip}`);
+ await lucia.invalidateSession(session.id);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+ event.locals.user = null;
+ event.locals.session = null;
+ return resolve(event);
+ }
+
+ event.locals.user = user;
+ event.locals.session = session;
+ return resolve(event);
+};
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
new file mode 100644
index 0000000..a005691
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
new file mode 100644
index 0000000..a7b0cf7
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
new file mode 100644
index 0000000..236bcad
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
new file mode 100644
index 0000000..2ec67dc
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
new file mode 100644
index 0000000..f78b97a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
new file mode 100644
index 0000000..1835d91
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
new file mode 100644
index 0000000..a64ee76
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
new file mode 100644
index 0000000..f0a19a8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
new file mode 100644
index 0000000..7ef2b5f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
new file mode 100644
index 0000000..b22d1d5
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog.svelte b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog.svelte
new file mode 100644
index 0000000..7ea78bb
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/alert-dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/alert-dialog/index.ts b/server/dashboard/src/lib/components/ui/alert-dialog/index.ts
new file mode 100644
index 0000000..269538e
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/alert-dialog/index.ts
@@ -0,0 +1,37 @@
+import Root from "./alert-dialog.svelte";
+import Portal from "./alert-dialog-portal.svelte";
+import Trigger from "./alert-dialog-trigger.svelte";
+import Title from "./alert-dialog-title.svelte";
+import Action from "./alert-dialog-action.svelte";
+import Cancel from "./alert-dialog-cancel.svelte";
+import Footer from "./alert-dialog-footer.svelte";
+import Header from "./alert-dialog-header.svelte";
+import Overlay from "./alert-dialog-overlay.svelte";
+import Content from "./alert-dialog-content.svelte";
+import Description from "./alert-dialog-description.svelte";
+
+export {
+ Root,
+ Title,
+ Action,
+ Cancel,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ //
+ Root as AlertDialog,
+ Title as AlertDialogTitle,
+ Action as AlertDialogAction,
+ Cancel as AlertDialogCancel,
+ Portal as AlertDialogPortal,
+ Footer as AlertDialogFooter,
+ Header as AlertDialogHeader,
+ Trigger as AlertDialogTrigger,
+ Overlay as AlertDialogOverlay,
+ Content as AlertDialogContent,
+ Description as AlertDialogDescription,
+};
diff --git a/server/dashboard/src/lib/components/ui/button/button.svelte b/server/dashboard/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..a8296ae
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+{#if href}
+
+ {@render children?.()}
+
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/button/index.ts b/server/dashboard/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..fb585d7
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/button/index.ts
@@ -0,0 +1,17 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+} from "./button.svelte";
+
+export {
+ Root,
+ type ButtonProps as Props,
+ //
+ Root as Button,
+ buttonVariants,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+};
diff --git a/server/dashboard/src/lib/components/ui/card/card-action.svelte b/server/dashboard/src/lib/components/ui/card/card-action.svelte
new file mode 100644
index 0000000..cc36c56
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-action.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-content.svelte b/server/dashboard/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..bc90b83
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-description.svelte b/server/dashboard/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..9b20ac7
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-footer.svelte b/server/dashboard/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..2d4d0f2
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-header.svelte b/server/dashboard/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..2501788
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card-title.svelte b/server/dashboard/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..7447231
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/card.svelte b/server/dashboard/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..99448cc
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/card/index.ts b/server/dashboard/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..4d3fce4
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/card/index.ts
@@ -0,0 +1,25 @@
+import Root from "./card.svelte";
+import Content from "./card-content.svelte";
+import Description from "./card-description.svelte";
+import Footer from "./card-footer.svelte";
+import Header from "./card-header.svelte";
+import Title from "./card-title.svelte";
+import Action from "./card-action.svelte";
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Action,
+ //
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle,
+ Action as CardAction,
+};
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte
new file mode 100644
index 0000000..840b2f6
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte
new file mode 100644
index 0000000..5c6ee6d
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-content.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+ {@render children?.()}
+ {#if showCloseButton}
+
+
+ Close
+
+ {/if}
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte
new file mode 100644
index 0000000..3845023
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte
new file mode 100644
index 0000000..e7ff446
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte
new file mode 100644
index 0000000..4e5c447
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte
new file mode 100644
index 0000000..f81ad83
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte
new file mode 100644
index 0000000..ccfa79c
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte
new file mode 100644
index 0000000..e4d4b34
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte
new file mode 100644
index 0000000..9d1e801
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/dialog.svelte b/server/dashboard/src/lib/components/ui/dialog/dialog.svelte
new file mode 100644
index 0000000..211672c
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/dialog.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/dialog/index.ts b/server/dashboard/src/lib/components/ui/dialog/index.ts
new file mode 100644
index 0000000..076cef5
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/dialog/index.ts
@@ -0,0 +1,34 @@
+import Root from "./dialog.svelte";
+import Portal from "./dialog-portal.svelte";
+import Title from "./dialog-title.svelte";
+import Footer from "./dialog-footer.svelte";
+import Header from "./dialog-header.svelte";
+import Overlay from "./dialog-overlay.svelte";
+import Content from "./dialog-content.svelte";
+import Description from "./dialog-description.svelte";
+import Trigger from "./dialog-trigger.svelte";
+import Close from "./dialog-close.svelte";
+
+export {
+ Root,
+ Title,
+ Portal,
+ Footer,
+ Header,
+ Trigger,
+ Overlay,
+ Content,
+ Description,
+ Close,
+ //
+ Root as Dialog,
+ Title as DialogTitle,
+ Portal as DialogPortal,
+ Footer as DialogFooter,
+ Header as DialogHeader,
+ Trigger as DialogTrigger,
+ Overlay as DialogOverlay,
+ Content as DialogContent,
+ Description as DialogDescription,
+ Close as DialogClose,
+};
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-content.svelte b/server/dashboard/src/lib/components/ui/empty/empty-content.svelte
new file mode 100644
index 0000000..f5a9c68
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-content.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-description.svelte b/server/dashboard/src/lib/components/ui/empty/empty-description.svelte
new file mode 100644
index 0000000..85a866c
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-description.svelte
@@ -0,0 +1,23 @@
+
+
+a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-header.svelte b/server/dashboard/src/lib/components/ui/empty/empty-header.svelte
new file mode 100644
index 0000000..296eaf8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-media.svelte b/server/dashboard/src/lib/components/ui/empty/empty-media.svelte
new file mode 100644
index 0000000..0b4e45d
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-media.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty-title.svelte b/server/dashboard/src/lib/components/ui/empty/empty-title.svelte
new file mode 100644
index 0000000..8c237aa
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty-title.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/empty.svelte b/server/dashboard/src/lib/components/ui/empty/empty.svelte
new file mode 100644
index 0000000..4ccf060
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/empty.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/empty/index.ts b/server/dashboard/src/lib/components/ui/empty/index.ts
new file mode 100644
index 0000000..ae4c106
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/empty/index.ts
@@ -0,0 +1,22 @@
+import Root from "./empty.svelte";
+import Header from "./empty-header.svelte";
+import Media from "./empty-media.svelte";
+import Title from "./empty-title.svelte";
+import Description from "./empty-description.svelte";
+import Content from "./empty-content.svelte";
+
+export {
+ Root,
+ Header,
+ Media,
+ Title,
+ Description,
+ Content,
+ //
+ Root as Empty,
+ Header as EmptyHeader,
+ Media as EmptyMedia,
+ Title as EmptyTitle,
+ Description as EmptyDescription,
+ Content as EmptyContent,
+};
diff --git a/server/dashboard/src/lib/components/ui/input/index.ts b/server/dashboard/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..f47b6d3
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/input/index.ts
@@ -0,0 +1,7 @@
+import Root from "./input.svelte";
+
+export {
+ Root,
+ //
+ Root as Input,
+};
diff --git a/server/dashboard/src/lib/components/ui/input/input.svelte b/server/dashboard/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..ff1a4c8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,52 @@
+
+
+{#if type === "file"}
+
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/label/index.ts b/server/dashboard/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..8bfca0b
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/label/index.ts
@@ -0,0 +1,7 @@
+import Root from "./label.svelte";
+
+export {
+ Root,
+ //
+ Root as Label,
+};
diff --git a/server/dashboard/src/lib/components/ui/label/label.svelte b/server/dashboard/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..d71afbc
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/index.ts b/server/dashboard/src/lib/components/ui/select/index.ts
new file mode 100644
index 0000000..4dec358
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/index.ts
@@ -0,0 +1,37 @@
+import Root from "./select.svelte";
+import Group from "./select-group.svelte";
+import Label from "./select-label.svelte";
+import Item from "./select-item.svelte";
+import Content from "./select-content.svelte";
+import Trigger from "./select-trigger.svelte";
+import Separator from "./select-separator.svelte";
+import ScrollDownButton from "./select-scroll-down-button.svelte";
+import ScrollUpButton from "./select-scroll-up-button.svelte";
+import GroupHeading from "./select-group-heading.svelte";
+import Portal from "./select-portal.svelte";
+
+export {
+ Root,
+ Group,
+ Label,
+ Item,
+ Content,
+ Trigger,
+ Separator,
+ ScrollDownButton,
+ ScrollUpButton,
+ GroupHeading,
+ Portal,
+ //
+ Root as Select,
+ Group as SelectGroup,
+ Label as SelectLabel,
+ Item as SelectItem,
+ Content as SelectContent,
+ Trigger as SelectTrigger,
+ Separator as SelectSeparator,
+ ScrollDownButton as SelectScrollDownButton,
+ ScrollUpButton as SelectScrollUpButton,
+ GroupHeading as SelectGroupHeading,
+ Portal as SelectPortal,
+};
diff --git a/server/dashboard/src/lib/components/ui/select/select-content.svelte b/server/dashboard/src/lib/components/ui/select/select-content.svelte
new file mode 100644
index 0000000..4b9ca43
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-content.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte b/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte
new file mode 100644
index 0000000..1fab5f0
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-group-heading.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-group.svelte b/server/dashboard/src/lib/components/ui/select/select-group.svelte
new file mode 100644
index 0000000..a1f43bf
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-item.svelte b/server/dashboard/src/lib/components/ui/select/select-item.svelte
new file mode 100644
index 0000000..b85eef6
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-item.svelte
@@ -0,0 +1,38 @@
+
+
+
+ {#snippet children({ selected, highlighted })}
+
+ {#if selected}
+
+ {/if}
+
+ {#if childrenProp}
+ {@render childrenProp({ selected, highlighted })}
+ {:else}
+ {label || value}
+ {/if}
+ {/snippet}
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-label.svelte b/server/dashboard/src/lib/components/ui/select/select-label.svelte
new file mode 100644
index 0000000..4696025
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-label.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-portal.svelte b/server/dashboard/src/lib/components/ui/select/select-portal.svelte
new file mode 100644
index 0000000..424bcdd
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte b/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte
new file mode 100644
index 0000000..3629205
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-scroll-down-button.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte b/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte
new file mode 100644
index 0000000..1aa2300
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-scroll-up-button.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-separator.svelte b/server/dashboard/src/lib/components/ui/select/select-separator.svelte
new file mode 100644
index 0000000..0eac3eb
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-separator.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select-trigger.svelte b/server/dashboard/src/lib/components/ui/select/select-trigger.svelte
new file mode 100644
index 0000000..dbb81df
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/server/dashboard/src/lib/components/ui/select/select.svelte b/server/dashboard/src/lib/components/ui/select/select.svelte
new file mode 100644
index 0000000..05eb663
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/select/select.svelte
@@ -0,0 +1,11 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/separator/index.ts b/server/dashboard/src/lib/components/ui/separator/index.ts
new file mode 100644
index 0000000..82442d2
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/separator/index.ts
@@ -0,0 +1,7 @@
+import Root from "./separator.svelte";
+
+export {
+ Root,
+ //
+ Root as Separator,
+};
diff --git a/server/dashboard/src/lib/components/ui/separator/separator.svelte b/server/dashboard/src/lib/components/ui/separator/separator.svelte
new file mode 100644
index 0000000..f40999f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/separator/separator.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/index.ts b/server/dashboard/src/lib/components/ui/sheet/index.ts
new file mode 100644
index 0000000..28d7da1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/index.ts
@@ -0,0 +1,34 @@
+import Root from "./sheet.svelte";
+import Portal from "./sheet-portal.svelte";
+import Trigger from "./sheet-trigger.svelte";
+import Close from "./sheet-close.svelte";
+import Overlay from "./sheet-overlay.svelte";
+import Content from "./sheet-content.svelte";
+import Header from "./sheet-header.svelte";
+import Footer from "./sheet-footer.svelte";
+import Title from "./sheet-title.svelte";
+import Description from "./sheet-description.svelte";
+
+export {
+ Root,
+ Close,
+ Trigger,
+ Portal,
+ Overlay,
+ Content,
+ Header,
+ Footer,
+ Title,
+ Description,
+ //
+ Root as Sheet,
+ Close as SheetClose,
+ Trigger as SheetTrigger,
+ Portal as SheetPortal,
+ Overlay as SheetOverlay,
+ Content as SheetContent,
+ Header as SheetHeader,
+ Footer as SheetFooter,
+ Title as SheetTitle,
+ Description as SheetDescription,
+};
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte
new file mode 100644
index 0000000..ae382c1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte
new file mode 100644
index 0000000..065fe04
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-content.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+ Close
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte
new file mode 100644
index 0000000..333b17a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-description.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte
new file mode 100644
index 0000000..dd9ed84
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-footer.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte
new file mode 100644
index 0000000..757a6a5
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte
new file mode 100644
index 0000000..345e197
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-overlay.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte
new file mode 100644
index 0000000..f3085a3
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte
new file mode 100644
index 0000000..9fda327
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-title.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte
new file mode 100644
index 0000000..e266975
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sheet/sheet.svelte b/server/dashboard/src/lib/components/ui/sheet/sheet.svelte
new file mode 100644
index 0000000..5bf9783
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sheet/sheet.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/constants.ts b/server/dashboard/src/lib/components/ui/sidebar/constants.ts
new file mode 100644
index 0000000..4de4435
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/constants.ts
@@ -0,0 +1,6 @@
+export const SIDEBAR_COOKIE_NAME = "sidebar:state";
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = "16rem";
+export const SIDEBAR_WIDTH_MOBILE = "18rem";
+export const SIDEBAR_WIDTH_ICON = "3rem";
+export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
diff --git a/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts b/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts
new file mode 100644
index 0000000..15248ad
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/context.svelte.ts
@@ -0,0 +1,81 @@
+import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
+import { getContext, setContext } from "svelte";
+import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
+
+type Getter = () => T;
+
+export type SidebarStateProps = {
+ /**
+ * A getter function that returns the current open state of the sidebar.
+ * We use a getter function here to support `bind:open` on the `Sidebar.Provider`
+ * component.
+ */
+ open: Getter;
+
+ /**
+ * A function that sets the open state of the sidebar. To support `bind:open`, we need
+ * a source of truth for changing the open state to ensure it will be synced throughout
+ * the sub-components and any `bind:` references.
+ */
+ setOpen: (open: boolean) => void;
+};
+
+class SidebarState {
+ readonly props: SidebarStateProps;
+ open = $derived.by(() => this.props.open());
+ openMobile = $state(false);
+ setOpen: SidebarStateProps["setOpen"];
+ #isMobile: IsMobile;
+ state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
+
+ constructor(props: SidebarStateProps) {
+ this.setOpen = props.setOpen;
+ this.#isMobile = new IsMobile();
+ this.props = props;
+ }
+
+ // Convenience getter for checking if the sidebar is mobile
+ // without this, we would need to use `sidebar.isMobile.current` everywhere
+ get isMobile() {
+ return this.#isMobile.current;
+ }
+
+ // Event handler to apply to the ``
+ handleShortcutKeydown = (e: KeyboardEvent) => {
+ if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ this.toggle();
+ }
+ };
+
+ setOpenMobile = (value: boolean) => {
+ this.openMobile = value;
+ };
+
+ toggle = () => {
+ return this.#isMobile.current
+ ? (this.openMobile = !this.openMobile)
+ : this.setOpen(!this.open);
+ };
+}
+
+const SYMBOL_KEY = "scn-sidebar";
+
+/**
+ * Instantiates a new `SidebarState` instance and sets it in the context.
+ *
+ * @param props The constructor props for the `SidebarState` class.
+ * @returns The `SidebarState` instance.
+ */
+export function setSidebar(props: SidebarStateProps): SidebarState {
+ return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
+}
+
+/**
+ * Retrieves the `SidebarState` instance from the context. This is a class instance,
+ * so you cannot destructure it.
+ * @returns The `SidebarState` instance.
+ */
+export function useSidebar(): SidebarState {
+ return getContext(Symbol.for(SYMBOL_KEY));
+}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/index.ts b/server/dashboard/src/lib/components/ui/sidebar/index.ts
new file mode 100644
index 0000000..318a341
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/index.ts
@@ -0,0 +1,75 @@
+import { useSidebar } from "./context.svelte.js";
+import Content from "./sidebar-content.svelte";
+import Footer from "./sidebar-footer.svelte";
+import GroupAction from "./sidebar-group-action.svelte";
+import GroupContent from "./sidebar-group-content.svelte";
+import GroupLabel from "./sidebar-group-label.svelte";
+import Group from "./sidebar-group.svelte";
+import Header from "./sidebar-header.svelte";
+import Input from "./sidebar-input.svelte";
+import Inset from "./sidebar-inset.svelte";
+import MenuAction from "./sidebar-menu-action.svelte";
+import MenuBadge from "./sidebar-menu-badge.svelte";
+import MenuButton from "./sidebar-menu-button.svelte";
+import MenuItem from "./sidebar-menu-item.svelte";
+import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
+import MenuSubButton from "./sidebar-menu-sub-button.svelte";
+import MenuSubItem from "./sidebar-menu-sub-item.svelte";
+import MenuSub from "./sidebar-menu-sub.svelte";
+import Menu from "./sidebar-menu.svelte";
+import Provider from "./sidebar-provider.svelte";
+import Rail from "./sidebar-rail.svelte";
+import Separator from "./sidebar-separator.svelte";
+import Trigger from "./sidebar-trigger.svelte";
+import Root from "./sidebar.svelte";
+
+export {
+ Content,
+ Footer,
+ Group,
+ GroupAction,
+ GroupContent,
+ GroupLabel,
+ Header,
+ Input,
+ Inset,
+ Menu,
+ MenuAction,
+ MenuBadge,
+ MenuButton,
+ MenuItem,
+ MenuSkeleton,
+ MenuSub,
+ MenuSubButton,
+ MenuSubItem,
+ Provider,
+ Rail,
+ Root,
+ Separator,
+ //
+ Root as Sidebar,
+ Content as SidebarContent,
+ Footer as SidebarFooter,
+ Group as SidebarGroup,
+ GroupAction as SidebarGroupAction,
+ GroupContent as SidebarGroupContent,
+ GroupLabel as SidebarGroupLabel,
+ Header as SidebarHeader,
+ Input as SidebarInput,
+ Inset as SidebarInset,
+ Menu as SidebarMenu,
+ MenuAction as SidebarMenuAction,
+ MenuBadge as SidebarMenuBadge,
+ MenuButton as SidebarMenuButton,
+ MenuItem as SidebarMenuItem,
+ MenuSkeleton as SidebarMenuSkeleton,
+ MenuSub as SidebarMenuSub,
+ MenuSubButton as SidebarMenuSubButton,
+ MenuSubItem as SidebarMenuSubItem,
+ Provider as SidebarProvider,
+ Rail as SidebarRail,
+ Separator as SidebarSeparator,
+ Trigger as SidebarTrigger,
+ Trigger,
+ useSidebar,
+};
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte
new file mode 100644
index 0000000..f121800
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte
new file mode 100644
index 0000000..6259cb9
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-footer.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte
new file mode 100644
index 0000000..a76dfe1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-action.svelte
@@ -0,0 +1,36 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte
new file mode 100644
index 0000000..415255f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-content.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte
new file mode 100644
index 0000000..b2e72b6
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group-label.svelte
@@ -0,0 +1,34 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte
new file mode 100644
index 0000000..ec18a69
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-group.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte
new file mode 100644
index 0000000..a1b2db1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-header.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte
new file mode 100644
index 0000000..19b3666
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-input.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte
new file mode 100644
index 0000000..7d6d459
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-inset.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
new file mode 100644
index 0000000..d3fe295
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-action.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
new file mode 100644
index 0000000..e8ecdb4
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
new file mode 100644
index 0000000..0acd1ec
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-button.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+{#snippet Button({ props }: { props?: Record })}
+ {@const mergedProps = mergeProps(buttonProps, props)}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {/if}
+{/snippet}
+
+{#if !tooltipContent}
+ {@render Button({})}
+{:else}
+
+
+ {#snippet child({ props })}
+ {@render Button({ props })}
+ {/snippet}
+
+
+ {#if typeof tooltipContent === "string"}
+ {tooltipContent}
+ {:else if tooltipContent}
+ {@render tooltipContent()}
+ {/if}
+
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
new file mode 100644
index 0000000..4db4453
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
new file mode 100644
index 0000000..68604e2
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
@@ -0,0 +1,36 @@
+
+
+
+ {#if showIcon}
+
+ {/if}
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
new file mode 100644
index 0000000..c8cd4ff
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
@@ -0,0 +1,43 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
new file mode 100644
index 0000000..681d0f1
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
new file mode 100644
index 0000000..76bd1d9
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte
new file mode 100644
index 0000000..946ccce
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-menu.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte
new file mode 100644
index 0000000..5b0d0aa
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-provider.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte
new file mode 100644
index 0000000..704d54f
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-rail.svelte
@@ -0,0 +1,36 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte
new file mode 100644
index 0000000..5a7deda
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-separator.svelte
@@ -0,0 +1,19 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte
new file mode 100644
index 0000000..1825182
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar-trigger.svelte
@@ -0,0 +1,35 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte b/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte
new file mode 100644
index 0000000..bac55d8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/sidebar/sidebar.svelte
@@ -0,0 +1,104 @@
+
+
+{#if collapsible === "none"}
+
+ {@render children?.()}
+
+{:else if sidebar.isMobile}
+ sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
+ {...restProps}
+ >
+
+
+{:else}
+
+{/if}
diff --git a/server/dashboard/src/lib/components/ui/skeleton/index.ts b/server/dashboard/src/lib/components/ui/skeleton/index.ts
new file mode 100644
index 0000000..186db21
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/skeleton/index.ts
@@ -0,0 +1,7 @@
+import Root from "./skeleton.svelte";
+
+export {
+ Root,
+ //
+ Root as Skeleton,
+};
diff --git a/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte b/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte
new file mode 100644
index 0000000..c7e3d26
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/skeleton/skeleton.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/table/index.ts b/server/dashboard/src/lib/components/ui/table/index.ts
new file mode 100644
index 0000000..14695c8
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/index.ts
@@ -0,0 +1,28 @@
+import Root from "./table.svelte";
+import Body from "./table-body.svelte";
+import Caption from "./table-caption.svelte";
+import Cell from "./table-cell.svelte";
+import Footer from "./table-footer.svelte";
+import Head from "./table-head.svelte";
+import Header from "./table-header.svelte";
+import Row from "./table-row.svelte";
+
+export {
+ Root,
+ Body,
+ Caption,
+ Cell,
+ Footer,
+ Head,
+ Header,
+ Row,
+ //
+ Root as Table,
+ Body as TableBody,
+ Caption as TableCaption,
+ Cell as TableCell,
+ Footer as TableFooter,
+ Head as TableHead,
+ Header as TableHeader,
+ Row as TableRow,
+};
diff --git a/server/dashboard/src/lib/components/ui/table/table-body.svelte b/server/dashboard/src/lib/components/ui/table/table-body.svelte
new file mode 100644
index 0000000..29e9687
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-body.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-caption.svelte b/server/dashboard/src/lib/components/ui/table/table-caption.svelte
new file mode 100644
index 0000000..4696cff
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-caption.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-cell.svelte b/server/dashboard/src/lib/components/ui/table/table-cell.svelte
new file mode 100644
index 0000000..2c0c26a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-cell.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+ |
diff --git a/server/dashboard/src/lib/components/ui/table/table-footer.svelte b/server/dashboard/src/lib/components/ui/table/table-footer.svelte
new file mode 100644
index 0000000..b9b14eb
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-footer.svelte
@@ -0,0 +1,20 @@
+
+
+tr]:last:border-b-0", className)}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-head.svelte b/server/dashboard/src/lib/components/ui/table/table-head.svelte
new file mode 100644
index 0000000..b67a6f9
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-head.svelte
@@ -0,0 +1,23 @@
+
+
+
+ {@render children?.()}
+ |
diff --git a/server/dashboard/src/lib/components/ui/table/table-header.svelte b/server/dashboard/src/lib/components/ui/table/table-header.svelte
new file mode 100644
index 0000000..f47d259
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-header.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table-row.svelte b/server/dashboard/src/lib/components/ui/table/table-row.svelte
new file mode 100644
index 0000000..0df769e
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table-row.svelte
@@ -0,0 +1,23 @@
+
+
+svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+ className
+ )}
+ {...restProps}
+>
+ {@render children?.()}
+
diff --git a/server/dashboard/src/lib/components/ui/table/table.svelte b/server/dashboard/src/lib/components/ui/table/table.svelte
new file mode 100644
index 0000000..a334956
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/table/table.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ {@render children?.()}
+
+
diff --git a/server/dashboard/src/lib/components/ui/textarea/index.ts b/server/dashboard/src/lib/components/ui/textarea/index.ts
new file mode 100644
index 0000000..ace797a
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/textarea/index.ts
@@ -0,0 +1,7 @@
+import Root from "./textarea.svelte";
+
+export {
+ Root,
+ //
+ Root as Textarea,
+};
diff --git a/server/dashboard/src/lib/components/ui/textarea/textarea.svelte b/server/dashboard/src/lib/components/ui/textarea/textarea.svelte
new file mode 100644
index 0000000..124e9d0
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/textarea/textarea.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/index.ts b/server/dashboard/src/lib/components/ui/tooltip/index.ts
new file mode 100644
index 0000000..1718604
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/index.ts
@@ -0,0 +1,19 @@
+import Root from "./tooltip.svelte";
+import Trigger from "./tooltip-trigger.svelte";
+import Content from "./tooltip-content.svelte";
+import Provider from "./tooltip-provider.svelte";
+import Portal from "./tooltip-portal.svelte";
+
+export {
+ Root,
+ Trigger,
+ Content,
+ Provider,
+ Portal,
+ //
+ Root as Tooltip,
+ Content as TooltipContent,
+ Trigger as TooltipTrigger,
+ Provider as TooltipProvider,
+ Portal as TooltipPortal,
+};
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte
new file mode 100644
index 0000000..2662522
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-content.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+ {@render children?.()}
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte
new file mode 100644
index 0000000..d234f7d
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte
new file mode 100644
index 0000000..8150bef
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-provider.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte
new file mode 100644
index 0000000..1acdaa4
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte b/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte
new file mode 100644
index 0000000..0b0f9ce
--- /dev/null
+++ b/server/dashboard/src/lib/components/ui/tooltip/tooltip.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/server/dashboard/src/lib/hooks/is-mobile.svelte.ts b/server/dashboard/src/lib/hooks/is-mobile.svelte.ts
new file mode 100644
index 0000000..4829c00
--- /dev/null
+++ b/server/dashboard/src/lib/hooks/is-mobile.svelte.ts
@@ -0,0 +1,9 @@
+import { MediaQuery } from "svelte/reactivity";
+
+const DEFAULT_MOBILE_BREAKPOINT = 768;
+
+export class IsMobile extends MediaQuery {
+ constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
+ super(`max-width: ${breakpoint - 1}px`);
+ }
+}
diff --git a/server/dashboard/src/lib/schema.ts b/server/dashboard/src/lib/schema.ts
new file mode 100644
index 0000000..df3e117
--- /dev/null
+++ b/server/dashboard/src/lib/schema.ts
@@ -0,0 +1,74 @@
+import {
+ mysqlTable,
+ int,
+ varchar,
+ text,
+ json,
+ mysqlEnum,
+ timestamp,
+ datetime,
+ boolean,
+ customType
+} from 'drizzle-orm/mysql-core';
+
+const longblob = customType<{ data: Buffer }>({
+ dataType() {
+ return 'longblob';
+ }
+});
+
+export const bugReports = mysqlTable('bug_reports', {
+ id: int('id').autoincrement().primaryKey(),
+ name: varchar('name', { length: 255 }).notNull(),
+ email: varchar('email', { length: 255 }).notNull(),
+ description: text('description').notNull(),
+ hwid: varchar('hwid', { length: 255 }).notNull().default(''),
+ hostname: varchar('hostname', { length: 255 }).notNull().default(''),
+ os_user: varchar('os_user', { length: 255 }).notNull().default(''),
+ submitter_ip: varchar('submitter_ip', { length: 45 }).notNull().default(''),
+ system_info: json('system_info'),
+ status: mysqlEnum('status', ['new', 'in_review', 'resolved', 'closed']).notNull().default('new'),
+ created_at: timestamp('created_at').notNull().defaultNow(),
+ updated_at: timestamp('updated_at').notNull().defaultNow().onUpdateNow()
+});
+
+export const bugReportFiles = mysqlTable('bug_report_files', {
+ id: int('id').autoincrement().primaryKey(),
+ report_id: int('report_id')
+ .notNull()
+ .references(() => bugReports.id, { onDelete: 'cascade' }),
+ file_role: mysqlEnum('file_role', [
+ 'screenshot',
+ 'mail_file',
+ 'localstorage',
+ 'config',
+ 'system_info'
+ ]).notNull(),
+ filename: varchar('filename', { length: 255 }).notNull(),
+ mime_type: varchar('mime_type', { length: 127 }).notNull().default('application/octet-stream'),
+ file_size: int('file_size').notNull().default(0),
+ data: longblob('data').notNull(),
+ created_at: timestamp('created_at').notNull().defaultNow()
+});
+
+export const userTable = mysqlTable('user', {
+ id: varchar('id', { length: 255 }).primaryKey(),
+ username: varchar('username', { length: 255 }).notNull().unique(),
+ displayname: varchar('displayname', { length: 255 }).notNull().default(''),
+ passwordHash: varchar('password_hash', { length: 255 }).notNull(),
+ role: mysqlEnum('role', ['admin', 'user']).notNull().default('user'),
+ enabled: boolean('enabled').notNull().default(true),
+ createdAt: timestamp('created_at').notNull().defaultNow()
+});
+
+export const sessionTable = mysqlTable('session', {
+ id: varchar('id', { length: 255 }).primaryKey(),
+ userId: varchar('user_id', { length: 255 })
+ .notNull()
+ .references(() => userTable.id),
+ expiresAt: datetime('expires_at').notNull()
+});
+
+export type BugReport = typeof bugReports.$inferSelect;
+export type BugReportFile = typeof bugReportFiles.$inferSelect;
+export type BugReportStatus = BugReport['status'];
diff --git a/server/dashboard/src/lib/server/auth.ts b/server/dashboard/src/lib/server/auth.ts
new file mode 100644
index 0000000..cf3b188
--- /dev/null
+++ b/server/dashboard/src/lib/server/auth.ts
@@ -0,0 +1,38 @@
+import { Lucia } from 'lucia';
+import { DrizzleMySQLAdapter } from '@lucia-auth/adapter-drizzle';
+import { db } from './db';
+import { sessionTable, userTable } from '$lib/schema';
+import { dev } from '$app/environment';
+
+const adapter = new DrizzleMySQLAdapter(db, sessionTable, userTable);
+
+export const lucia = new Lucia(adapter, {
+ sessionCookie: {
+ attributes: {
+ secure: !dev
+ }
+ },
+ getUserAttributes: (attributes) => {
+ return {
+ username: attributes.username,
+ role: attributes.role,
+ displayname: attributes.displayname,
+ enabled: attributes.enabled
+ };
+ }
+});
+
+declare module 'lucia' {
+ interface Register {
+ Lucia: typeof lucia;
+ DatabaseUserAttributes: DatabaseUserAttributes;
+ }
+}
+
+interface DatabaseUserAttributes {
+ username: string;
+ role: 'admin' | 'user';
+ displayname: string;
+ enabled: boolean;
+}
+// End of file
diff --git a/server/dashboard/src/lib/server/db.ts b/server/dashboard/src/lib/server/db.ts
new file mode 100644
index 0000000..d93f0ea
--- /dev/null
+++ b/server/dashboard/src/lib/server/db.ts
@@ -0,0 +1,16 @@
+import { drizzle } from 'drizzle-orm/mysql2';
+import mysql from 'mysql2/promise';
+import * as schema from '$lib/schema';
+import { env } from '$env/dynamic/private';
+
+const pool = mysql.createPool({
+ host: env.MYSQL_HOST || 'localhost',
+ port: Number(env.MYSQL_PORT) || 3306,
+ user: env.MYSQL_USER || 'emly',
+ password: env.MYSQL_PASSWORD,
+ database: env.MYSQL_DATABASE || 'emly_bugreports',
+ connectionLimit: 10,
+ idleTimeout: 60000
+});
+
+export const db = drizzle(pool, { schema, mode: 'default' });
diff --git a/server/dashboard/src/lib/server/logger.ts b/server/dashboard/src/lib/server/logger.ts
new file mode 100644
index 0000000..d2769d1
--- /dev/null
+++ b/server/dashboard/src/lib/server/logger.ts
@@ -0,0 +1,42 @@
+import { mkdirSync, appendFileSync, existsSync } from "fs";
+import { join } from "path";
+
+let logFilePath: string | null = null;
+
+/**
+ * Initialize the logger. Creates the logs/ directory if needed
+ * and opens the log file in append mode.
+ */
+export function initLogger(filename = "dashboard.log"): void {
+ const logsDir = join(process.cwd(), "logs");
+ if (!existsSync(logsDir)) {
+ mkdirSync(logsDir, { recursive: true });
+ }
+ logFilePath = join(logsDir, filename);
+ Log("LOGGER", "Logger initialized. Writing to:", logFilePath);
+}
+
+/**
+ * Log a timestamped, source-tagged message to stdout and the log file.
+ * Format: [YYYY-MM-DD] - [HH:MM:SS] - [source] - message
+ */
+export function Log(source: string, ...args: unknown[]): void {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10);
+ const time = now.toTimeString().slice(0, 8);
+ const msg = args
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
+ .join(" ");
+
+ const line = `[${date}] - [${time}] - [${source}] - ${msg}`;
+
+ console.log(line);
+
+ if (logFilePath) {
+ try {
+ appendFileSync(logFilePath, line + "\n");
+ } catch {
+ // If file write fails, stdout logging still works
+ }
+ }
+}
diff --git a/server/dashboard/src/lib/stores/presence.svelte.ts b/server/dashboard/src/lib/stores/presence.svelte.ts
new file mode 100644
index 0000000..87250fa
--- /dev/null
+++ b/server/dashboard/src/lib/stores/presence.svelte.ts
@@ -0,0 +1,95 @@
+import { browser } from '$app/environment';
+import { page } from '$app/stores';
+
+export interface ActiveUser {
+ userId: string;
+ username: string;
+ displayname: string;
+ currentPath: string;
+ reportId: number | null;
+ lastSeen: number;
+}
+
+class PresenceStore {
+ activeUsers = $state([]);
+ connected = $state(false);
+
+ private eventSource: EventSource | null = null;
+ private heartbeatInterval: ReturnType | null = null;
+ private currentPath = '/';
+ private unsubscribePage: (() => void) | null = null;
+
+ connect() {
+ if (!browser || this.eventSource) return;
+
+ this.eventSource = new EventSource('/api/presence');
+
+ this.eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ this.activeUsers = data;
+ } catch {
+ // ignore parse errors
+ }
+ };
+
+ this.eventSource.onopen = () => {
+ this.connected = true;
+ };
+
+ this.eventSource.onerror = () => {
+ this.connected = false;
+ // EventSource auto-reconnects
+ };
+
+ // Track current page and send heartbeats
+ this.unsubscribePage = page.subscribe((p) => {
+ this.currentPath = p.url.pathname;
+ });
+
+ // Send heartbeat every 15 seconds
+ this.sendHeartbeat();
+ this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), 15000);
+ }
+
+ disconnect() {
+ if (this.eventSource) {
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ }
+ if (this.unsubscribePage) {
+ this.unsubscribePage();
+ this.unsubscribePage = null;
+ }
+ this.connected = false;
+ this.activeUsers = [];
+ }
+
+ private async sendHeartbeat() {
+ try {
+ const reportMatch = this.currentPath.match(/^\/reports\/(\d+)/);
+ const reportId = reportMatch ? Number(reportMatch[1]) : null;
+
+ await fetch('/api/presence/heartbeat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ currentPath: this.currentPath,
+ reportId
+ })
+ });
+ } catch {
+ // ignore heartbeat failures
+ }
+ }
+
+ getViewersForReport(reportId: number): ActiveUser[] {
+ return this.activeUsers.filter((u) => u.reportId === reportId);
+ }
+}
+
+export const presence = new PresenceStore();
diff --git a/server/dashboard/src/lib/utils.ts b/server/dashboard/src/lib/utils.ts
new file mode 100644
index 0000000..3cebf9a
--- /dev/null
+++ b/server/dashboard/src/lib/utils.ts
@@ -0,0 +1,46 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
+
+export function formatDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+}
+
+export const statusColors: Record = {
+ new: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+ in_review: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+ resolved: 'bg-green-500/20 text-green-400 border-green-500/30',
+ closed: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
+};
+
+export const statusLabels: Record = {
+ new: 'New',
+ in_review: 'In Review',
+ resolved: 'Resolved',
+ closed: 'Closed'
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChild = T extends { child?: any } ? Omit : T;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type WithoutChildren = T extends { children?: any } ? Omit : T;
+export type WithoutChildrenOrChild = WithoutChildren>;
+export type WithElementRef = T & { ref?: U | null };
diff --git a/server/dashboard/src/routes/+error.svelte b/server/dashboard/src/routes/+error.svelte
new file mode 100644
index 0000000..024d2f9
--- /dev/null
+++ b/server/dashboard/src/routes/+error.svelte
@@ -0,0 +1,14 @@
+
+
+
+
{$page.status}
+
{$page.error?.message || 'Something went wrong'}
+
+ Back to Reports
+
+
diff --git a/server/dashboard/src/routes/+layout.server.ts b/server/dashboard/src/routes/+layout.server.ts
new file mode 100644
index 0000000..d95b760
--- /dev/null
+++ b/server/dashboard/src/routes/+layout.server.ts
@@ -0,0 +1,25 @@
+import type { LayoutServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { eq, count } from 'drizzle-orm';
+
+export const load: LayoutServerLoad = async ({ locals, url }) => {
+ if (url.pathname === '/login') {
+ return { newCount: 0, user: null };
+ }
+
+ if (!locals.user) {
+ redirect(302, '/login');
+ }
+
+ const [result] = await db
+ .select({ count: count() })
+ .from(bugReports)
+ .where(eq(bugReports.status, 'new'));
+
+ return {
+ newCount: result.count,
+ user: locals.user
+ };
+};
diff --git a/server/dashboard/src/routes/+layout.svelte b/server/dashboard/src/routes/+layout.svelte
new file mode 100644
index 0000000..7c0c80c
--- /dev/null
+++ b/server/dashboard/src/routes/+layout.svelte
@@ -0,0 +1,169 @@
+
+
+{#if !data.user}
+ {@render children()}
+{:else}
+
+
+
+
+
+ EMLy Dashboard
+
+
+
+
+ Menu
+
+
+
+
+ {#snippet child({ props })}
+
+
+ Reports
+
+ {/snippet}
+
+
+ {#if data.user.role === 'admin'}
+
+
+ {#snippet child({ props })}
+
+
+ Users
+
+ {/snippet}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+{/if}
diff --git a/server/dashboard/src/routes/+page.server.ts b/server/dashboard/src/routes/+page.server.ts
new file mode 100644
index 0000000..2f4c72f
--- /dev/null
+++ b/server/dashboard/src/routes/+page.server.ts
@@ -0,0 +1,70 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq, like, or, count, sql, desc, and } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ url }) => {
+ const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
+ const pageSize = Math.min(50, Math.max(10, Number(url.searchParams.get('pageSize')) || 20));
+ const status = url.searchParams.get('status') || '';
+ const search = url.searchParams.get('search') || '';
+
+ const conditions = [];
+
+ if (status && ['new', 'in_review', 'resolved', 'closed'].includes(status)) {
+ conditions.push(eq(bugReports.status, status as 'new' | 'in_review' | 'resolved' | 'closed'));
+ }
+
+ if (search) {
+ conditions.push(
+ or(
+ like(bugReports.hostname, `%${search}%`),
+ like(bugReports.os_user, `%${search}%`),
+ like(bugReports.name, `%${search}%`),
+ like(bugReports.email, `%${search}%`)
+ )
+ );
+ }
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ // Get total count
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(bugReports)
+ .where(where);
+
+ // Get paginated reports with file count
+ const reports = await db
+ .select({
+ id: bugReports.id,
+ name: bugReports.name,
+ email: bugReports.email,
+ hostname: bugReports.hostname,
+ os_user: bugReports.os_user,
+ status: bugReports.status,
+ created_at: bugReports.created_at,
+ file_count: count(bugReportFiles.id)
+ })
+ .from(bugReports)
+ .leftJoin(bugReportFiles, eq(bugReports.id, bugReportFiles.report_id))
+ .where(where)
+ .groupBy(bugReports.id)
+ .orderBy(desc(bugReports.created_at))
+ .limit(pageSize)
+ .offset((page - 1) * pageSize);
+
+ return {
+ reports,
+ pagination: {
+ page,
+ pageSize,
+ total,
+ totalPages: Math.ceil(total / pageSize)
+ },
+ filters: {
+ status,
+ search
+ }
+ };
+};
diff --git a/server/dashboard/src/routes/+page.svelte b/server/dashboard/src/routes/+page.svelte
new file mode 100644
index 0000000..ab285dd
--- /dev/null
+++ b/server/dashboard/src/routes/+page.svelte
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+ e.key === 'Enter' && applyFilters()}
+ class="w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+
+
applyFilters()}>
+
+ {statusOptions.find((o) => o.value === statusFilter)?.label || 'All statuses'}
+
+
+
+ {#each statusOptions as option}
+
+ {/each}
+
+
+
+
+ {#if data.filters.search || data.filters.status}
+
+ {/if}
+
+
+
+ {#if data.reports.length === 0}
+
+
+
+
+
+
+ No reports found
+
+ There are no bug reports matching your current filters.
+
+
+ {#if data.filters.search || data.filters.status}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {:else}
+
+
+
+
+ ID
+ Hostname
+ User
+ Reporter
+ Status
+ Files
+ Created
+
+
+
+ {#each data.reports as report (report.id)}
+ goto(`/reports/${report.id}`)}
+ >
+ #{report.id}
+ {report.hostname || '—'}
+ {report.os_user || '—'}
+
+ {report.name}
+ {report.email}
+
+
+
+ {statusLabels[report.status]}
+
+
+
+ {#if report.file_count > 0}
+
+
+ {report.file_count}
+
+ {:else}
+ —
+ {/if}
+
+
+ {formatDate(report.created_at)}
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {#if data.pagination.totalPages > 1}
+
+
+ Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to {Math.min(
+ data.pagination.page * data.pagination.pageSize,
+ data.pagination.total
+ )} of {data.pagination.total} reports
+
+
+
+ {#each Array.from({ length: data.pagination.totalPages }, (_, i) => i + 1) as p}
+ {#if p === 1 || p === data.pagination.totalPages || (p >= data.pagination.page - 1 && p <= data.pagination.page + 1)}
+
+ {:else if p === data.pagination.page - 2 || p === data.pagination.page + 2}
+ ...
+ {/if}
+ {/each}
+
+
+
+ {/if}
+
+
diff --git a/server/dashboard/src/routes/api/presence/+server.ts b/server/dashboard/src/routes/api/presence/+server.ts
new file mode 100644
index 0000000..ff88606
--- /dev/null
+++ b/server/dashboard/src/routes/api/presence/+server.ts
@@ -0,0 +1,49 @@
+import type { RequestHandler } from './$types';
+import { presenceMap, sseClients, broadcastPresence } from './state';
+
+export const GET: RequestHandler = async ({ locals }) => {
+ if (!locals.user) {
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ const userId = locals.user.id;
+
+ const stream = new ReadableStream({
+ start(controller) {
+ const clientId = `${userId}-${Date.now()}`;
+
+ sseClients.set(clientId, controller);
+
+ // Send current state immediately
+ const users = Array.from(presenceMap.values()).filter(
+ (u) => Date.now() - u.lastSeen < 60000
+ );
+ controller.enqueue(`data: ${JSON.stringify(users)}\n\n`);
+
+ // Cleanup on close
+ const cleanup = () => {
+ sseClients.delete(clientId);
+ presenceMap.delete(userId);
+ broadcastPresence();
+ };
+
+ // Use a heartbeat to detect disconnection
+ const keepAlive = setInterval(() => {
+ try {
+ controller.enqueue(': keepalive\n\n');
+ } catch {
+ cleanup();
+ clearInterval(keepAlive);
+ }
+ }, 30000);
+ }
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive'
+ }
+ });
+};
diff --git a/server/dashboard/src/routes/api/presence/heartbeat/+server.ts b/server/dashboard/src/routes/api/presence/heartbeat/+server.ts
new file mode 100644
index 0000000..6b12a07
--- /dev/null
+++ b/server/dashboard/src/routes/api/presence/heartbeat/+server.ts
@@ -0,0 +1,25 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { presenceMap, broadcastPresence } from '../state';
+
+export const POST: RequestHandler = async ({ request, locals }) => {
+ if (!locals.user) {
+ error(401, 'Unauthorized');
+ }
+
+ const body = await request.json();
+ const { currentPath, reportId } = body;
+
+ presenceMap.set(locals.user.id, {
+ userId: locals.user.id,
+ username: locals.user.username,
+ displayname: locals.user.displayname,
+ currentPath: currentPath || '/',
+ reportId: reportId || null,
+ lastSeen: Date.now()
+ });
+
+ broadcastPresence();
+
+ return json({ ok: true });
+};
diff --git a/server/dashboard/src/routes/api/presence/state.ts b/server/dashboard/src/routes/api/presence/state.ts
new file mode 100644
index 0000000..5b274bb
--- /dev/null
+++ b/server/dashboard/src/routes/api/presence/state.ts
@@ -0,0 +1,20 @@
+import type { ActiveUser } from '$lib/stores/presence.svelte';
+
+// In-memory presence tracking - shared between SSE and heartbeat endpoints
+export const presenceMap = new Map();
+
+// SSE client connections
+export const sseClients = new Map();
+
+export function broadcastPresence() {
+ const users = Array.from(presenceMap.values()).filter((u) => Date.now() - u.lastSeen < 60000);
+ const data = `data: ${JSON.stringify(users)}\n\n`;
+
+ for (const [clientId, controller] of sseClients.entries()) {
+ try {
+ controller.enqueue(data);
+ } catch {
+ sseClients.delete(clientId);
+ }
+ }
+}
diff --git a/server/dashboard/src/routes/api/reports/[id]/download/+server.ts b/server/dashboard/src/routes/api/reports/[id]/download/+server.ts
new file mode 100644
index 0000000..51595c2
--- /dev/null
+++ b/server/dashboard/src/routes/api/reports/[id]/download/+server.ts
@@ -0,0 +1,68 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+import JSZip from 'jszip';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [report] = await db
+ .select()
+ .from(bugReports)
+ .where(eq(bugReports.id, id))
+ .limit(1);
+
+ if (!report) throw error(404, 'Report not found');
+
+ const files = await db
+ .select()
+ .from(bugReportFiles)
+ .where(eq(bugReportFiles.report_id, id));
+
+ const zip = new JSZip();
+
+ // Add report metadata as text file
+ const reportText = [
+ `Bug Report #${report.id}`,
+ `========================`,
+ ``,
+ `Name: ${report.name}`,
+ `Email: ${report.email}`,
+ `Hostname: ${report.hostname}`,
+ `OS User: ${report.os_user}`,
+ `HWID: ${report.hwid}`,
+ `IP: ${report.submitter_ip}`,
+ `Status: ${report.status}`,
+ `Created: ${report.created_at.toISOString()}`,
+ `Updated: ${report.updated_at.toISOString()}`,
+ ``,
+ `Description:`,
+ `------------`,
+ report.description,
+ ``,
+ ...(report.system_info
+ ? [`System Info:`, `------------`, JSON.stringify(report.system_info, null, 2)]
+ : [])
+ ].join('\n');
+
+ zip.file('report.txt', reportText);
+
+ // Add all files
+ for (const file of files) {
+ const folder = file.file_role;
+ zip.file(`${folder}/${file.filename}`, file.data);
+ }
+
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
+
+ return new Response(new Uint8Array(zipBuffer), {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': `attachment; filename="report-${id}.zip"`,
+ 'Content-Length': String(zipBuffer.length)
+ }
+ });
+};
diff --git a/server/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts b/server/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts
new file mode 100644
index 0000000..980bdfd
--- /dev/null
+++ b/server/dashboard/src/routes/api/reports/[id]/files/[fileId]/+server.ts
@@ -0,0 +1,28 @@
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReportFiles } from '$lib/schema';
+import { eq, and } from 'drizzle-orm';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const reportId = Number(params.id);
+ const fileId = Number(params.fileId);
+
+ if (isNaN(reportId) || isNaN(fileId)) throw error(400, 'Invalid ID');
+
+ const [file] = await db
+ .select()
+ .from(bugReportFiles)
+ .where(and(eq(bugReportFiles.id, fileId), eq(bugReportFiles.report_id, reportId)))
+ .limit(1);
+
+ if (!file) throw error(404, 'File not found');
+
+ return new Response(new Uint8Array(file.data), {
+ headers: {
+ 'Content-Type': file.mime_type,
+ 'Content-Disposition': `inline; filename="${file.filename}"`,
+ 'Content-Length': String(file.file_size)
+ }
+ });
+};
diff --git a/server/dashboard/src/routes/api/reports/refresh/+server.ts b/server/dashboard/src/routes/api/reports/refresh/+server.ts
new file mode 100644
index 0000000..5506987
--- /dev/null
+++ b/server/dashboard/src/routes/api/reports/refresh/+server.ts
@@ -0,0 +1,13 @@
+import type { RequestHandler } from './$types';
+import { json } from '@sveltejs/kit';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { count } from 'drizzle-orm';
+
+export const GET: RequestHandler = async () => {
+ const [{ total }] = await db
+ .select({ total: count() })
+ .from(bugReports);
+
+ return json({ total });
+};
\ No newline at end of file
diff --git a/server/dashboard/src/routes/login/+page.server.ts b/server/dashboard/src/routes/login/+page.server.ts
new file mode 100644
index 0000000..c1ca5af
--- /dev/null
+++ b/server/dashboard/src/routes/login/+page.server.ts
@@ -0,0 +1,63 @@
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { verify } from '@node-rs/argon2';
+import { lucia } from '$lib/server/auth';
+import { db } from '$lib/server/db';
+import { userTable } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (locals.user) {
+ redirect(302, '/');
+ }
+};
+
+export const actions: Actions = {
+ default: async ({ request, cookies }) => {
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const password = formData.get('password');
+
+ if (typeof username !== 'string' || typeof password !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ const [user] = await db
+ .select()
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (!user) {
+ return fail(400, { message: 'Invalid username or password' });
+ }
+
+ const validPassword = await verify(user.passwordHash, password, {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+
+ if (!validPassword) {
+ return fail(400, { message: 'Invalid username or password' });
+ }
+
+ if (!user.enabled) {
+ return fail(403, { message: 'Account is disabled' });
+ }
+
+ const session = await lucia.createSession(user.id, {});
+ const sessionCookie = lucia.createSessionCookie(session.id);
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+
+ redirect(302, '/');
+ }
+};
diff --git a/server/dashboard/src/routes/login/+page.svelte b/server/dashboard/src/routes/login/+page.svelte
new file mode 100644
index 0000000..2c74fee
--- /dev/null
+++ b/server/dashboard/src/routes/login/+page.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
EMLy Dashboard
+
Sign in to continue
+
+
+ {#if form?.message}
+
+ {form.message}
+
+ {/if}
+
+
+
+
diff --git a/server/dashboard/src/routes/logout/+page.server.ts b/server/dashboard/src/routes/logout/+page.server.ts
new file mode 100644
index 0000000..67b748e
--- /dev/null
+++ b/server/dashboard/src/routes/logout/+page.server.ts
@@ -0,0 +1,24 @@
+import type { Actions, PageServerLoad } from './$types';
+import { redirect } from '@sveltejs/kit';
+import { lucia } from '$lib/server/auth';
+
+export const load: PageServerLoad = async () => {
+ redirect(302, '/');
+};
+
+export const actions: Actions = {
+ default: async ({ locals, cookies }) => {
+ if (!locals.session) {
+ redirect(302, '/login');
+ }
+
+ await lucia.invalidateSession(locals.session.id);
+ const sessionCookie = lucia.createBlankSessionCookie();
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: '.',
+ ...sessionCookie.attributes
+ });
+
+ redirect(302, '/login');
+ }
+};
diff --git a/server/dashboard/src/routes/reports/[id]/+page.server.ts b/server/dashboard/src/routes/reports/[id]/+page.server.ts
new file mode 100644
index 0000000..50cfe11
--- /dev/null
+++ b/server/dashboard/src/routes/reports/[id]/+page.server.ts
@@ -0,0 +1,45 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports, bugReportFiles } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [report] = await db
+ .select()
+ .from(bugReports)
+ .where(eq(bugReports.id, id))
+ .limit(1);
+
+ if (!report) throw error(404, 'Report not found');
+
+ const files = await db
+ .select({
+ id: bugReportFiles.id,
+ report_id: bugReportFiles.report_id,
+ file_role: bugReportFiles.file_role,
+ filename: bugReportFiles.filename,
+ mime_type: bugReportFiles.mime_type,
+ file_size: bugReportFiles.file_size,
+ created_at: bugReportFiles.created_at
+ })
+ .from(bugReportFiles)
+ .where(eq(bugReportFiles.report_id, id));
+
+ return {
+ report: {
+ ...report,
+ system_info: report.system_info ? JSON.stringify(report.system_info, null, 2) : null,
+ created_at: report.created_at.toISOString(),
+ updated_at: report.updated_at.toISOString()
+ },
+ files: files.map((f) => ({
+ ...f,
+ created_at: f.created_at.toISOString()
+ })),
+ currentUserId: locals.user?.id ?? ''
+ };
+};
diff --git a/server/dashboard/src/routes/reports/[id]/+page.svelte b/server/dashboard/src/routes/reports/[id]/+page.svelte
new file mode 100644
index 0000000..25a4e2e
--- /dev/null
+++ b/server/dashboard/src/routes/reports/[id]/+page.svelte
@@ -0,0 +1,304 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Report #{data.report.id}
+
+ {statusLabels[data.report.status]}
+
+
+
+ Submitted by {data.report.name} ({data.report.email})
+
+
+
+ {#if otherViewers.length > 0}
+
+
+ {#each otherViewers as viewer}
+
+
+
+ {(viewer.displayname || viewer.username).charAt(0).toUpperCase()}
+
+
+
+
+ {viewer.displayname || viewer.username} is viewing this report
+
+
+ {/each}
+
+ {/if}
+
+
updateStatus(val)}
+ >
+
+ {statusLabels[data.report.status]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Hostname
+
{data.report.hostname || '—'}
+
+
+
OS User
+
{data.report.os_user || '—'}
+
+
+
HWID
+
{data.report.hwid || '—'}
+
+
+
IP Address
+
{data.report.submitter_ip || '—'}
+
+
+
Created
+
{formatDate(data.report.created_at)}
+
+
+
Updated
+
{formatDate(data.report.updated_at)}
+
+
+
+
+
+
+
+
+ Description
+
+
+ {data.report.description}
+
+
+
+
+ {#if data.report.system_info}
+
+
+ System Information
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+ Attached Files ({data.files.length})
+
+
+ {#if data.files.length > 0}
+
+ {@const screenshots = data.files.filter((f) => f.file_role === 'screenshot')}
+ {#if screenshots.length > 0}
+
+ {#each screenshots as file}
+
+

+
{file.filename}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+ Role
+ Filename
+ Size
+ Action
+
+
+
+ {#each data.files as file}
+ {@const Icon = roleIcons[file.file_role] || FileText}
+
+
+
+
+ {roleLabels[file.file_role] || file.file_role}
+
+
+ {file.filename}
+ {formatBytes(file.file_size)}
+
+
+
+
+ {/each}
+
+
+
+ {:else}
+ No files attached.
+ {/if}
+
+
+
+
+
+
+
+
+ Delete Report
+
+ Are you sure you want to delete report #{data.report.id}? This will permanently remove the
+ report and all attached files. This action cannot be undone.
+
+
+
+ (showDeleteDialog = false)}>Cancel
+
+
+
+
diff --git a/server/dashboard/src/routes/reports/[id]/+server.ts b/server/dashboard/src/routes/reports/[id]/+server.ts
new file mode 100644
index 0000000..1b67138
--- /dev/null
+++ b/server/dashboard/src/routes/reports/[id]/+server.ts
@@ -0,0 +1,39 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { bugReports } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+export const PATCH: RequestHandler = async ({ params, request }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const body = await request.json();
+ const { status } = body;
+
+ if (!['new', 'in_review', 'resolved', 'closed'].includes(status)) {
+ throw error(400, 'Invalid status');
+ }
+
+ const [result] = await db
+ .update(bugReports)
+ .set({ status })
+ .where(eq(bugReports.id, id));
+
+ if (result.affectedRows === 0) throw error(404, 'Report not found');
+
+ return json({ success: true });
+};
+
+export const DELETE: RequestHandler = async ({ params }) => {
+ const id = Number(params.id);
+ if (isNaN(id)) throw error(400, 'Invalid report ID');
+
+ const [result] = await db
+ .delete(bugReports)
+ .where(eq(bugReports.id, id));
+
+ if (result.affectedRows === 0) throw error(404, 'Report not found');
+
+ return json({ success: true });
+};
diff --git a/server/dashboard/src/routes/users/+page.server.ts b/server/dashboard/src/routes/users/+page.server.ts
new file mode 100644
index 0000000..018e496
--- /dev/null
+++ b/server/dashboard/src/routes/users/+page.server.ts
@@ -0,0 +1,256 @@
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { hash } from '@node-rs/argon2';
+import { generateIdFromEntropySize } from 'lucia';
+import { db } from '$lib/server/db';
+import { userTable } from '$lib/schema';
+import { eq } from 'drizzle-orm';
+
+const PASSWORD_MIN_LENGTH = 8;
+const PASSWORD_MAX_LENGTH = 255;
+
+function validatePassword(password: string): string | null {
+ if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) {
+ return `Password must be ${PASSWORD_MIN_LENGTH}-${PASSWORD_MAX_LENGTH} characters`;
+ }
+ if (!/[A-Z]/.test(password)) {
+ return 'Password must contain at least one uppercase letter';
+ }
+ if (!/[a-z]/.test(password)) {
+ return 'Password must contain at least one lowercase letter';
+ }
+ if (!/[0-9]/.test(password)) {
+ return 'Password must contain at least one number';
+ }
+ if (!/[^A-Za-z0-9]/.test(password)) {
+ return 'Password must contain at least one special character';
+ }
+ return null;
+}
+
+async function hashPassword(password: string): Promise {
+ return hash(password, {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+}
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ redirect(302, '/');
+ }
+
+ const users = await db
+ .select({
+ id: userTable.id,
+ username: userTable.username,
+ displayname: userTable.displayname,
+ role: userTable.role,
+ enabled: userTable.enabled,
+ createdAt: userTable.createdAt
+ })
+ .from(userTable)
+ .orderBy(userTable.createdAt);
+
+ return { users };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const username = formData.get('username');
+ const displayname = formData.get('displayname') || '';
+ const password = formData.get('password');
+ const confirmPassword = formData.get('confirmPassword');
+ const role = formData.get('role');
+
+ if (
+ typeof username !== 'string' ||
+ typeof displayname !== 'string' ||
+ typeof password !== 'string' ||
+ typeof confirmPassword !== 'string' ||
+ typeof role !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (!username || !password) {
+ return fail(400, { message: 'Username and password are required' });
+ }
+
+ if (username.length < 3 || username.length > 255) {
+ return fail(400, { message: 'Username must be 3-255 characters' });
+ }
+
+ if (password !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(password);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ if (role !== 'admin' && role !== 'user') {
+ return fail(400, { message: 'Invalid role' });
+ }
+
+ // Check if username already exists
+ const [existing] = await db
+ .select({ id: userTable.id })
+ .from(userTable)
+ .where(eq(userTable.username, username))
+ .limit(1);
+
+ if (existing) {
+ return fail(400, { message: 'Username already exists' });
+ }
+
+ const passwordHash = await hashPassword(password);
+ const userId = generateIdFromEntropySize(10);
+
+ await db.insert(userTable).values({
+ id: userId,
+ username,
+ displayname,
+ passwordHash,
+ role: role as 'admin' | 'user'
+ });
+
+ return { success: true };
+ },
+
+ updateDisplayname: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+ const displayname = formData.get('displayname');
+
+ if (typeof userId !== 'string' || typeof displayname !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ await db.update(userTable).set({ displayname }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ resetPassword: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId === 'string' && userId === locals.user.id) {
+ return fail(400, { message: 'Cannot reset your own password from here' });
+ }
+ const newPassword = formData.get('newPassword');
+ const confirmPassword = formData.get('confirmPassword');
+
+ if (
+ typeof userId !== 'string' ||
+ typeof newPassword !== 'string' ||
+ typeof confirmPassword !== 'string'
+ ) {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (newPassword !== confirmPassword) {
+ return fail(400, { message: 'Passwords do not match' });
+ }
+
+ const passwordError = validatePassword(newPassword);
+ if (passwordError) {
+ return fail(400, { message: passwordError });
+ }
+
+ const passwordHash = await hashPassword(newPassword);
+
+ await db.update(userTable).set({ passwordHash }).where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ toggleEnabled: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ // Cannot disable yourself
+ if (userId === locals.user.id) {
+ return fail(400, { message: 'Cannot disable your own account' });
+ }
+
+ // Cannot disable other admins
+ const [targetUser] = await db
+ .select({ role: userTable.role, enabled: userTable.enabled })
+ .from(userTable)
+ .where(eq(userTable.id, userId))
+ .limit(1);
+
+ if (!targetUser) {
+ return fail(404, { message: 'User not found' });
+ }
+
+ if (targetUser.role === 'admin') {
+ return fail(400, { message: 'Cannot disable an admin user' });
+ }
+
+ await db
+ .update(userTable)
+ .set({ enabled: !targetUser.enabled })
+ .where(eq(userTable.id, userId));
+
+ return { success: true };
+ },
+
+ delete: async ({ request, locals }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ return fail(403, { message: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const userId = formData.get('userId');
+
+ if (typeof userId !== 'string') {
+ return fail(400, { message: 'Invalid input' });
+ }
+
+ if (userId === locals.user.id) {
+ return fail(400, { message: 'Cannot delete your own account' });
+ }
+
+ // Prevent deleting admin users
+ const [targetUser] = await db
+ .select({ role: userTable.role })
+ .from(userTable)
+ .where(eq(userTable.id, userId))
+ .limit(1);
+
+ if (targetUser?.role === 'admin') {
+ return fail(400, { message: 'Cannot delete an admin user' });
+ }
+
+ await db.delete(userTable).where(eq(userTable.id, userId));
+
+ return { success: true };
+ }
+};
diff --git a/server/dashboard/src/routes/users/+page.svelte b/server/dashboard/src/routes/users/+page.svelte
new file mode 100644
index 0000000..d7172c9
--- /dev/null
+++ b/server/dashboard/src/routes/users/+page.svelte
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+
+
+
+
+ | Username |
+ Display Name |
+ Role |
+ Status |
+ Created |
+ Actions |
+
+
+
+ {#each data.users as user (user.id)}
+
+ | {user.username} |
+ {user.displayname || '—'} |
+
+
+ {user.role}
+
+ |
+
+
+ {user.enabled ? 'Enabled' : 'Disabled'}
+
+ |
+
+ {user.createdAt ? formatDate(user.createdAt) : '—'}
+ |
+
+ {#if user.id === data.user?.id}
+ Current user
+ {:else}
+
+ {#if user.role !== 'admin'}
+
+ {/if}
+
+
+ {#if user.id !== data.user?.id && data.user?.role === 'admin'}
+
+ {/if}
+
+ {/if}
+ |
+
+ {:else}
+
+ |
+ No users found.
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ Create User
+ Add a new user account with role assignment
+
+
+
+
+
+
+
+
+
+
+ Reset Password
+
+ Set a new password for {resetUsername}
+
+
+
+
+
+
+
+
+
+
+
+ Update Display Name
+
+ Update display name for {selectedUser?.username}
+
+
+
+
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the user account
+ {selectedUser?.username} and remove their
+ data from our servers.
+
+
+
+ (deleteDialogOpen = false)}>Cancel
+
+
+
+
diff --git a/server/dashboard/svelte.config.js b/server/dashboard/svelte.config.js
new file mode 100644
index 0000000..b4b7de8
--- /dev/null
+++ b/server/dashboard/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/server/dashboard/tsconfig.json b/server/dashboard/tsconfig.json
new file mode 100644
index 0000000..a8f10c8
--- /dev/null
+++ b/server/dashboard/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/server/dashboard/vite.config.ts b/server/dashboard/vite.config.ts
new file mode 100644
index 0000000..bf699a8
--- /dev/null
+++ b/server/dashboard/vite.config.ts
@@ -0,0 +1,7 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()]
+});
diff --git a/server/emly-server.service b/server/emly-server.service
new file mode 100644
index 0000000..25fd717
--- /dev/null
+++ b/server/emly-server.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=EMLy Bug Report Server (Docker Compose)
+Requires=docker.service
+After=docker.service
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+WorkingDirectory=/opt/emly-server
+ExecStart=/usr/bin/docker compose up -d
+ExecStop=/usr/bin/docker compose down
+Restart=on-failure
+TimeoutStartSec=120
+
+[Install]
+WantedBy=multi-user.target
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..85edc65
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "emly-bugreport-server",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "bun run --watch src/index.ts",
+ "start": "bun run src/index.ts"
+ },
+ "dependencies": {
+ "@node-rs/argon2": "^2.0.2",
+ "elysia": "^1.2.0",
+ "mysql2": "^3.11.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/server/src/config.ts b/server/src/config.ts
new file mode 100644
index 0000000..ec1932b
--- /dev/null
+++ b/server/src/config.ts
@@ -0,0 +1,24 @@
+export const config = {
+ mysql: {
+ host: process.env.MYSQL_HOST || "localhost",
+ port: parseInt(process.env.MYSQL_PORT || "3306"),
+ user: process.env.MYSQL_USER || "emly",
+ password: process.env.MYSQL_PASSWORD || "",
+ database: process.env.MYSQL_DATABASE || "emly_bugreports",
+ },
+ apiKey: process.env.API_KEY || "",
+ adminKey: process.env.ADMIN_KEY || "",
+ port: parseInt(process.env.PORT || "3000"),
+ rateLimit: {
+ max: parseInt(process.env.RATE_LIMIT_MAX || "5"),
+ windowHours: parseInt(process.env.RATE_LIMIT_WINDOW_HOURS || "24"),
+ },
+} as const;
+
+// Validate required config on startup
+export function validateConfig(): void {
+ if (!config.apiKey) throw new Error("API_KEY is required");
+ if (!config.adminKey) throw new Error("ADMIN_KEY is required");
+ if (!config.mysql.password)
+ throw new Error("MYSQL_PASSWORD is required");
+}
diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts
new file mode 100644
index 0000000..6966d0a
--- /dev/null
+++ b/server/src/db/connection.ts
@@ -0,0 +1,28 @@
+import mysql from "mysql2/promise";
+import { config } from "../config";
+
+let pool: mysql.Pool | null = null;
+
+export function getPool(): mysql.Pool {
+ if (!pool) {
+ pool = mysql.createPool({
+ host: config.mysql.host,
+ port: config.mysql.port,
+ user: config.mysql.user,
+ password: config.mysql.password,
+ database: config.mysql.database,
+ waitForConnections: true,
+ connectionLimit: 10,
+ maxIdle: 5,
+ idleTimeout: 60000,
+ });
+ }
+ return pool;
+}
+
+export async function closePool(): Promise {
+ if (pool) {
+ await pool.end();
+ pool = null;
+ }
+}
diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts
new file mode 100644
index 0000000..28ce54d
--- /dev/null
+++ b/server/src/db/migrate.ts
@@ -0,0 +1,59 @@
+import { readFileSync } from "fs";
+import { join } from "path";
+import { randomUUID } from "crypto";
+import { hash } from "@node-rs/argon2";
+import { getPool } from "./connection";
+import { Log } from "../logger";
+
+export async function runMigrations(): Promise {
+ const pool = getPool();
+ const schemaPath = join(import.meta.dir, "schema.sql");
+ const schema = readFileSync(schemaPath, "utf-8");
+
+ // Split on semicolons, filter empty statements
+ const statements = schema
+ .split(";")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+
+ for (const statement of statements) {
+ await pool.execute(statement);
+ }
+
+ // Additive migrations for existing databases
+ const alterMigrations = [
+ `ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid`,
+ `ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname`,
+ `ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname)`,
+ `ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user)`,
+ `ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role`,
+ ];
+
+ for (const migration of alterMigrations) {
+ try {
+ await pool.execute(migration);
+ } catch {
+ // Column/index already exists — safe to ignore
+ }
+ }
+
+ // Seed default admin user if user table is empty
+ const [rows] = await pool.execute("SELECT COUNT(*) as count FROM `user`");
+ const userCount = (rows as Array<{ count: number }>)[0].count;
+ if (userCount === 0) {
+ const passwordHash = await hash("admin", {
+ memoryCost: 19456,
+ timeCost: 2,
+ outputLen: 32,
+ parallelism: 1
+ });
+ const id = randomUUID();
+ await pool.execute(
+ "INSERT INTO `user` (`id`, `username`, `password_hash`, `role`) VALUES (?, ?, ?, ?)",
+ [id, "admin", passwordHash, "admin"]
+ );
+ Log("MIGRATE", "Default admin user created (username: admin, password: admin)");
+ }
+
+ Log("MIGRATE", "Database migrations completed");
+}
diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql
new file mode 100644
index 0000000..228f52a
--- /dev/null
+++ b/server/src/db/schema.sql
@@ -0,0 +1,55 @@
+CREATE TABLE IF NOT EXISTS `bug_reports` (
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ `name` VARCHAR(255) NOT NULL,
+ `email` VARCHAR(255) NOT NULL,
+ `description` TEXT NOT NULL,
+ `hwid` VARCHAR(255) NOT NULL DEFAULT '',
+ `hostname` VARCHAR(255) NOT NULL DEFAULT '',
+ `os_user` VARCHAR(255) NOT NULL DEFAULT '',
+ `submitter_ip` VARCHAR(45) NOT NULL DEFAULT '',
+ `system_info` JSON NULL,
+ `status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new',
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX `idx_status` (`status`),
+ INDEX `idx_hwid` (`hwid`),
+ INDEX `idx_hostname` (`hostname`),
+ INDEX `idx_os_user` (`os_user`),
+ INDEX `idx_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `bug_report_files` (
+ `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ `report_id` INT UNSIGNED NOT NULL,
+ `file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL,
+ `filename` VARCHAR(255) NOT NULL,
+ `mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream',
+ `file_size` INT UNSIGNED NOT NULL DEFAULT 0,
+ `data` LONGBLOB NOT NULL,
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE,
+ INDEX `idx_report_id` (`report_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `rate_limit_hwid` (
+ `hwid` VARCHAR(255) PRIMARY KEY,
+ `window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `count` INT UNSIGNED NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `user` (
+ `id` VARCHAR(255) PRIMARY KEY,
+ `username` VARCHAR(255) NOT NULL UNIQUE,
+ `password_hash` VARCHAR(255) NOT NULL,
+ `role` ENUM('admin', 'user') NOT NULL DEFAULT 'user',
+ `enabled` BOOLEAN NOT NULL DEFAULT TRUE,
+ `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `displayname` VARCHAR(255) NOT NULL DEFAULT ''
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `session` (
+ `id` VARCHAR(255) PRIMARY KEY,
+ `user_id` VARCHAR(255) NOT NULL,
+ `expires_at` DATETIME NOT NULL,
+ CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/server/src/index.ts b/server/src/index.ts
new file mode 100644
index 0000000..cddf314
--- /dev/null
+++ b/server/src/index.ts
@@ -0,0 +1,60 @@
+import { Elysia } from "elysia";
+import { config, validateConfig } from "./config";
+import { runMigrations } from "./db/migrate";
+import { closePool } from "./db/connection";
+import { bugReportRoutes } from "./routes/bugReports";
+import { adminRoutes } from "./routes/admin";
+import { initLogger, Log } from "./logger";
+
+// Initialize logger
+initLogger();
+
+// Validate environment
+validateConfig();
+
+// Run database migrations
+await runMigrations();
+
+const app = new Elysia()
+ .onRequest(({ request }) => {
+ const url = new URL(request.url);
+ const ip =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ "unknown";
+ Log("HTTP", `${request.method} ${url.pathname} from ${ip}`);
+ })
+ .onAfterResponse(({ request, set }) => {
+ const url = new URL(request.url);
+ Log("HTTP", `${request.method} ${url.pathname} -> ${set.status ?? 200}`);
+ })
+ .onError(({ error, set }) => {
+ Log("ERROR", "Unhandled error:", error);
+ set.status = 500;
+ return { success: false, message: "Internal server error" };
+ })
+ .get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
+ .use(bugReportRoutes)
+ .use(adminRoutes)
+ .listen({
+ port: config.port,
+ maxBody: 50 * 1024 * 1024, // 50MB
+ });
+
+Log(
+ "SERVER",
+ `EMLy Bug Report API running on http://localhost:${app.server?.port}`
+);
+
+// Graceful shutdown
+process.on("SIGINT", async () => {
+ Log("SERVER", "Shutting down (SIGINT)...");
+ await closePool();
+ process.exit(0);
+});
+
+process.on("SIGTERM", async () => {
+ Log("SERVER", "Shutting down (SIGTERM)...");
+ await closePool();
+ process.exit(0);
+});
diff --git a/server/src/logger.ts b/server/src/logger.ts
new file mode 100644
index 0000000..bc953a4
--- /dev/null
+++ b/server/src/logger.ts
@@ -0,0 +1,42 @@
+import { mkdirSync, appendFileSync, existsSync } from "fs";
+import { join } from "path";
+
+let logFilePath: string | null = null;
+
+/**
+ * Initialize the logger. Creates the logs/ directory if needed
+ * and opens the log file in append mode.
+ */
+export function initLogger(filename = "api.log"): void {
+ const logsDir = join(process.cwd(), "logs");
+ if (!existsSync(logsDir)) {
+ mkdirSync(logsDir, { recursive: true });
+ }
+ logFilePath = join(logsDir, filename);
+ Log("LOGGER", "Logger initialized. Writing to:", logFilePath);
+}
+
+/**
+ * Log a timestamped, source-tagged message to stdout and the log file.
+ * Format: [YYYY-MM-DD] - [HH:MM:SS] - [source] - message
+ */
+export function Log(source: string, ...args: unknown[]): void {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10);
+ const time = now.toTimeString().slice(0, 8);
+ const msg = args
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
+ .join(" ");
+
+ const line = `[${date}] - [${time}] - [${source}] - ${msg}`;
+
+ console.log(line);
+
+ if (logFilePath) {
+ try {
+ appendFileSync(logFilePath, line + "\n");
+ } catch {
+ // If file write fails, stdout logging still works
+ }
+ }
+}
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
new file mode 100644
index 0000000..df1e2b6
--- /dev/null
+++ b/server/src/middleware/auth.ts
@@ -0,0 +1,35 @@
+import { Elysia } from "elysia";
+import { config } from "../config";
+import { Log } from "../logger";
+
+export const apiKeyGuard = new Elysia({ name: "api-key-guard" }).derive(
+ { as: "scoped" },
+ ({ headers, error, request }) => {
+ const key = headers["x-api-key"];
+ if (!key || key !== config.apiKey) {
+ const ip =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ "unknown";
+ Log("AUTH", `Invalid API key from ip=${ip}`);
+ return error(401, { success: false, message: "Invalid or missing API key" });
+ }
+ return {};
+ }
+);
+
+export const adminKeyGuard = new Elysia({ name: "admin-key-guard" }).derive(
+ { as: "scoped" },
+ ({ headers, error, request }) => {
+ const key = headers["x-admin-key"];
+ if (!key || key !== config.adminKey) {
+ const ip =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ "unknown";
+ Log("AUTH", `Invalid admin key from ip=${ip}`);
+ return error(401, { success: false, message: "Invalid or missing admin key" });
+ }
+ return {};
+ }
+);
diff --git a/server/src/middleware/rateLimit.ts b/server/src/middleware/rateLimit.ts
new file mode 100644
index 0000000..70921d8
--- /dev/null
+++ b/server/src/middleware/rateLimit.ts
@@ -0,0 +1,72 @@
+import { Elysia } from "elysia";
+import { getPool } from "../db/connection";
+import { config } from "../config";
+import { Log } from "../logger";
+
+const excludedHwids = new Set([
+ // Add HWIDs here for development testing
+ "95e025d1-7567-462e-9354-ac88b965cd22",
+]);
+
+export const hwidRateLimit = new Elysia({ name: "hwid-rate-limit" }).derive(
+ { as: "scoped" },
+ // @ts-ignore
+ async ({ body, error }) => {
+ const hwid = (body as { hwid?: string })?.hwid;
+ if (!hwid || excludedHwids.has(hwid)) {
+ // No HWID provided or excluded, skip rate limiting
+ return {};
+ }
+
+ const pool = getPool();
+ const windowMs = config.rateLimit.windowHours * 60 * 60 * 1000;
+ const now = new Date();
+
+ // Get current rate limit entry
+ const [rows] = await pool.execute(
+ "SELECT window_start, count FROM rate_limit_hwid WHERE hwid = ?",
+ [hwid]
+ );
+
+ const entries = rows as { window_start: Date; count: number }[];
+
+ if (entries.length === 0) {
+ // First request from this HWID
+ await pool.execute(
+ "INSERT INTO rate_limit_hwid (hwid, window_start, count) VALUES (?, ?, 1)",
+ [hwid, now]
+ );
+ return {};
+ }
+
+ const entry = entries[0];
+ const windowStart = new Date(entry.window_start);
+ const elapsed = now.getTime() - windowStart.getTime();
+
+ if (elapsed > windowMs) {
+ // Window expired, reset
+ await pool.execute(
+ "UPDATE rate_limit_hwid SET window_start = ?, count = 1 WHERE hwid = ?",
+ [now, hwid]
+ );
+ return {};
+ }
+
+ if (entry.count >= config.rateLimit.max) {
+ const retryAfterMs = windowMs - elapsed;
+ const retryAfterMin = Math.ceil(retryAfterMs / 60000);
+ Log("RATELIMIT", `Rate limit hit hwid=${hwid} count=${entry.count}`);
+ return error(429, {
+ success: false,
+ message: `Rate limit exceeded. Try again in ${retryAfterMin} minutes.`,
+ });
+ }
+
+ // Increment count
+ await pool.execute(
+ "UPDATE rate_limit_hwid SET count = count + 1 WHERE hwid = ?",
+ [hwid]
+ );
+ return {};
+ }
+);
diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts
new file mode 100644
index 0000000..3bc3e72
--- /dev/null
+++ b/server/src/routes/admin.ts
@@ -0,0 +1,109 @@
+import { Elysia, t } from "elysia";
+import { adminKeyGuard } from "../middleware/auth";
+import {
+ listBugReports,
+ getBugReport,
+ getFile,
+ deleteBugReport,
+ updateBugReportStatus,
+} from "../services/bugReportService";
+import { Log } from "../logger";
+import type { BugReportStatus } from "../types";
+
+export const adminRoutes = new Elysia({ prefix: "/api/admin" })
+ .use(adminKeyGuard)
+ .get(
+ "/bug-reports",
+ async ({ query }) => {
+ const page = parseInt(query.page || "1");
+ const pageSize = Math.min(parseInt(query.pageSize || "20"), 100);
+ const status = query.status as BugReportStatus | undefined;
+
+ Log("ADMIN", `List bug reports page=${page} pageSize=${pageSize} status=${status || "all"}`);
+ return await listBugReports({ page, pageSize, status });
+ },
+ {
+ query: t.Object({
+ page: t.Optional(t.String()),
+ pageSize: t.Optional(t.String()),
+ status: t.Optional(
+ t.Union([
+ t.Literal("new"),
+ t.Literal("in_review"),
+ t.Literal("resolved"),
+ t.Literal("closed"),
+ ])
+ ),
+ }),
+ detail: { summary: "List bug reports (paginated)" },
+ }
+ )
+ .get(
+ "/bug-reports/:id",
+ async ({ params, error }) => {
+ Log("ADMIN", `Get bug report id=${params.id}`);
+ const result = await getBugReport(parseInt(params.id));
+ if (!result) return error(404, { success: false, message: "Report not found" });
+ return result;
+ },
+ {
+ params: t.Object({ id: t.String() }),
+ detail: { summary: "Get bug report with file metadata" },
+ }
+ )
+ .patch(
+ "/bug-reports/:id/status",
+ async ({ params, body, error }) => {
+ Log("ADMIN", `Update status id=${params.id} status=${body.status}`);
+ const updated = await updateBugReportStatus(
+ parseInt(params.id),
+ body.status
+ );
+ if (!updated)
+ return error(404, { success: false, message: "Report not found" });
+ return { success: true, message: "Status updated" };
+ },
+ {
+ params: t.Object({ id: t.String() }),
+ body: t.Object({
+ status: t.Union([
+ t.Literal("new"),
+ t.Literal("in_review"),
+ t.Literal("resolved"),
+ t.Literal("closed"),
+ ]),
+ }),
+ detail: { summary: "Update bug report status" },
+ }
+ )
+ .get(
+ "/bug-reports/:id/files/:fileId",
+ async ({ params, error, set }) => {
+ const file = await getFile(parseInt(params.id), parseInt(params.fileId));
+ if (!file)
+ return error(404, { success: false, message: "File not found" });
+
+ set.headers["content-type"] = file.mime_type;
+ set.headers["content-disposition"] =
+ `attachment; filename="${file.filename}"`;
+ return new Response(file.data);
+ },
+ {
+ params: t.Object({ id: t.String(), fileId: t.String() }),
+ detail: { summary: "Download a bug report file" },
+ }
+ )
+ .delete(
+ "/bug-reports/:id",
+ async ({ params, error }) => {
+ Log("ADMIN", `Delete bug report id=${params.id}`);
+ const deleted = await deleteBugReport(parseInt(params.id));
+ if (!deleted)
+ return error(404, { success: false, message: "Report not found" });
+ return { success: true, message: "Report deleted" };
+ },
+ {
+ params: t.Object({ id: t.String() }),
+ detail: { summary: "Delete a bug report and its files" },
+ }
+ );
diff --git a/server/src/routes/bugReports.ts b/server/src/routes/bugReports.ts
new file mode 100644
index 0000000..21bc61f
--- /dev/null
+++ b/server/src/routes/bugReports.ts
@@ -0,0 +1,107 @@
+import { Elysia, t } from "elysia";
+import { apiKeyGuard } from "../middleware/auth";
+import { hwidRateLimit } from "../middleware/rateLimit";
+import { createBugReport, addFile } from "../services/bugReportService";
+import { Log } from "../logger";
+import type { FileRole } from "../types";
+
+const FILE_ROLES: { field: string; role: FileRole; mime: string }[] = [
+ { field: "screenshot", role: "screenshot", mime: "image/png" },
+ { field: "mail_file", role: "mail_file", mime: "application/octet-stream" },
+ { field: "localstorage", role: "localstorage", mime: "application/json" },
+ { field: "config", role: "config", mime: "application/json" },
+];
+
+export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
+ .use(apiKeyGuard)
+ .use(hwidRateLimit)
+ .post(
+ "/",
+ async ({ body, request, set }) => {
+ const { name, email, description, hwid, hostname, os_user, system_info } = body;
+
+ // Get submitter IP from headers or connection
+ const submitterIp =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ "unknown";
+
+ Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`);
+
+ // Parse system_info — may arrive as a JSON string or already-parsed object
+ let systemInfo: Record | null = null;
+ if (system_info) {
+ if (typeof system_info === "string") {
+ try {
+ systemInfo = JSON.parse(system_info);
+ } catch {
+ systemInfo = null;
+ }
+ } else if (typeof system_info === "object") {
+ systemInfo = system_info as Record;
+ }
+ }
+
+ // Create the bug report
+ const reportId = await createBugReport({
+ name,
+ email,
+ description,
+ hwid: hwid || "",
+ hostname: hostname || "",
+ os_user: os_user || "",
+ submitter_ip: submitterIp,
+ system_info: systemInfo,
+ });
+
+ // Process file uploads
+ for (const { field, role, mime } of FILE_ROLES) {
+ const file = body[field as keyof typeof body];
+ if (file && file instanceof File) {
+ const buffer = Buffer.from(await file.arrayBuffer());
+ Log("BUGREPORT", `File uploaded: role=${role} size=${buffer.length} bytes`);
+ await addFile({
+ report_id: reportId,
+ file_role: role,
+ filename: file.name || `${field}.bin`,
+ mime_type: file.type || mime,
+ file_size: buffer.length,
+ data: buffer,
+ });
+ }
+ }
+
+ Log("BUGREPORT", `Created successfully with id=${reportId}`);
+
+ set.status = 201;
+ return {
+ success: true,
+ report_id: reportId,
+ message: "Bug report submitted successfully",
+ };
+ },
+ {
+ type: "multipart/form-data",
+ body: t.Object({
+ name: t.String(),
+ email: t.String(),
+ description: t.String(),
+ hwid: t.Optional(t.String()),
+ hostname: t.Optional(t.String()),
+ os_user: t.Optional(t.String()),
+ system_info: t.Optional(t.Any()),
+ screenshot: t.Optional(t.File()),
+ mail_file: t.Optional(t.File()),
+ localstorage: t.Optional(t.File()),
+ config: t.Optional(t.File()),
+ }),
+ response: {
+ 201: t.Object({
+ success: t.Boolean(),
+ report_id: t.Number(),
+ message: t.String(),
+ }),
+ },
+ detail: { summary: "Submit a bug report" },
+ }
+ );
diff --git a/server/src/services/bugReportService.ts b/server/src/services/bugReportService.ts
new file mode 100644
index 0000000..6ab6056
--- /dev/null
+++ b/server/src/services/bugReportService.ts
@@ -0,0 +1,163 @@
+import type { ResultSetHeader, RowDataPacket } from "mysql2";
+import { getPool } from "../db/connection";
+import type {
+ BugReport,
+ BugReportFile,
+ BugReportListItem,
+ BugReportStatus,
+ FileRole,
+ PaginatedResponse,
+} from "../types";
+
+export async function createBugReport(data: {
+ name: string;
+ email: string;
+ description: string;
+ hwid: string;
+ hostname: string;
+ os_user: string;
+ submitter_ip: string;
+ system_info: Record | null;
+}): Promise {
+ const pool = getPool();
+ const [result] = await pool.execute(
+ `INSERT INTO bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ data.name,
+ data.email,
+ data.description,
+ data.hwid,
+ data.hostname,
+ data.os_user,
+ data.submitter_ip,
+ data.system_info ? JSON.stringify(data.system_info) : null,
+ ]
+ );
+ return result.insertId;
+}
+
+export async function addFile(data: {
+ report_id: number;
+ file_role: FileRole;
+ filename: string;
+ mime_type: string;
+ file_size: number;
+ data: Buffer;
+}): Promise {
+ const pool = getPool();
+ const [result] = await pool.execute(
+ `INSERT INTO bug_report_files (report_id, file_role, filename, mime_type, file_size, data)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [
+ data.report_id,
+ data.file_role,
+ data.filename,
+ data.mime_type,
+ data.file_size,
+ data.data,
+ ]
+ );
+ return result.insertId;
+}
+
+export async function listBugReports(opts: {
+ page: number;
+ pageSize: number;
+ status?: BugReportStatus;
+}): Promise> {
+ const pool = getPool();
+ const { page, pageSize, status } = opts;
+ const offset = (page - 1) * pageSize;
+
+ let whereClause = "";
+ const params: unknown[] = [];
+
+ if (status) {
+ whereClause = "WHERE br.status = ?";
+ params.push(status);
+ }
+
+ const [countRows] = await pool.execute(
+ `SELECT COUNT(*) as total FROM bug_reports br ${whereClause}`,
+ params
+ );
+ const total = (countRows[0] as { total: number }).total;
+
+ const [rows] = await pool.execute(
+ `SELECT br.*, COUNT(bf.id) as file_count
+ FROM bug_reports br
+ LEFT JOIN bug_report_files bf ON bf.report_id = br.id
+ ${whereClause}
+ GROUP BY br.id
+ ORDER BY br.created_at DESC
+ LIMIT ? OFFSET ?`,
+ [...params, pageSize, offset]
+ );
+
+ return {
+ data: rows as BugReportListItem[],
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ };
+}
+
+export async function getBugReport(
+ id: number
+): Promise<{ report: BugReport; files: Omit[] } | null> {
+ const pool = getPool();
+
+ const [reportRows] = await pool.execute(
+ "SELECT * FROM bug_reports WHERE id = ?",
+ [id]
+ );
+
+ if ((reportRows as unknown[]).length === 0) return null;
+
+ const [fileRows] = await pool.execute(
+ "SELECT id, report_id, file_role, filename, mime_type, file_size, created_at FROM bug_report_files WHERE report_id = ?",
+ [id]
+ );
+
+ return {
+ report: reportRows[0] as BugReport,
+ files: fileRows as Omit[],
+ };
+}
+
+export async function getFile(
+ reportId: number,
+ fileId: number
+): Promise {
+ const pool = getPool();
+ const [rows] = await pool.execute(
+ "SELECT * FROM bug_report_files WHERE id = ? AND report_id = ?",
+ [fileId, reportId]
+ );
+
+ if ((rows as unknown[]).length === 0) return null;
+ return rows[0] as BugReportFile;
+}
+
+export async function deleteBugReport(id: number): Promise {
+ const pool = getPool();
+ const [result] = await pool.execute(
+ "DELETE FROM bug_reports WHERE id = ?",
+ [id]
+ );
+ return result.affectedRows > 0;
+}
+
+export async function updateBugReportStatus(
+ id: number,
+ status: BugReportStatus
+): Promise {
+ const pool = getPool();
+ const [result] = await pool.execute(
+ "UPDATE bug_reports SET status = ? WHERE id = ?",
+ [status, id]
+ );
+ return result.affectedRows > 0;
+}
diff --git a/server/src/types/index.ts b/server/src/types/index.ts
new file mode 100644
index 0000000..6b933a4
--- /dev/null
+++ b/server/src/types/index.ts
@@ -0,0 +1,57 @@
+export type BugReportStatus = "new" | "in_review" | "resolved" | "closed";
+
+export type FileRole =
+ | "screenshot"
+ | "mail_file"
+ | "localstorage"
+ | "config"
+ | "system_info";
+
+export interface BugReport {
+ id: number;
+ name: string;
+ email: string;
+ description: string;
+ hwid: string;
+ hostname: string;
+ os_user: string;
+ submitter_ip: string;
+ system_info: Record | null;
+ status: BugReportStatus;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface BugReportFile {
+ id: number;
+ report_id: number;
+ file_role: FileRole;
+ filename: string;
+ mime_type: string;
+ file_size: number;
+ data?: Buffer;
+ created_at: Date;
+}
+
+export interface BugReportListItem {
+ id: number;
+ name: string;
+ email: string;
+ description: string;
+ hwid: string;
+ hostname: string;
+ os_user: string;
+ submitter_ip: string;
+ status: BugReportStatus;
+ created_at: Date;
+ updated_at: Date;
+ file_count: number;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}
diff --git a/server/tsconfig.json b/server/tsconfig.json
new file mode 100644
index 0000000..3575ba1
--- /dev/null
+++ b/server/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "outDir": "dist",
+ "declaration": true,
+ "types": ["bun"]
+ },
+ "include": ["src/**/*.ts"]
+}