Compare commits
21 Commits
1eb76ea122
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ff819cb5 | ||
|
|
7c210ffe8f | ||
|
|
3e018d60f9 | ||
|
|
e525c657ac | ||
|
|
3f469b55cf | ||
|
|
cebcf8a8cb | ||
|
|
eb85d528d9 | ||
|
|
2cd25be2d6 | ||
|
|
b5da16b4f0 | ||
|
|
c52612825c | ||
|
|
ce13ad8124 | ||
|
|
92deee1b2e | ||
|
|
31a501f75b | ||
|
|
2cf28416ec | ||
|
|
eee31e5b99 | ||
|
|
0a11be5006 | ||
|
|
5da61ed853 | ||
|
|
671a712b08 | ||
|
|
6639bcd799 | ||
|
|
e8c5986df6 | ||
|
|
72cdfaab80 |
23
.env.example
23
.env.example
@@ -8,15 +8,9 @@ DATABASE_URL=${{project.DATABASE_URL}}
|
|||||||
INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
|
INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
|
||||||
DEBUG_KEY=${{project.DEBUG_KEY}}
|
DEBUG_KEY=${{project.DEBUG_KEY}}
|
||||||
|
|
||||||
PUBLIC_URL=${{project.PUBLIC_URL}}
|
ORCHESTRATOR_API_URL=${{project.ORCHESTRATOR_API_URL}}
|
||||||
|
PUBLIC_WS_SCRCPY_SVC_URL=${{project.PUBLIC_WS_SCRCPY_SVC_URL}}
|
||||||
PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}}
|
PUBLIC_FRONTEND_URL=${{project.PUBLIC_FRONTEND_URL}}
|
||||||
APP_BUILDER_API_URL=${{project.APP_BUILDER_API_URL}}
|
|
||||||
APP_BUILDER_ASSETS_PUBLIC_URL=${{project.APP_BUILDER_ASSETS_PUBLIC_URL}}
|
|
||||||
|
|
||||||
CLIENT_DOWNLOADED_APK_NAME=${{project.CLIENT_DOWNLOADED_APK_NAME}}
|
|
||||||
|
|
||||||
MOBILE_APP_API_URL=${{project.MOBILE_APP_API_URL}}
|
|
||||||
|
|
||||||
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
|
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
|
||||||
BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}}
|
BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}}
|
||||||
@@ -34,14 +28,3 @@ OTEL_EXPORTER_OTLP_HTTP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_HTTP_ENDPOINT}}
|
|||||||
OTEL_EXPORTER_OTLP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}}
|
OTEL_EXPORTER_OTLP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}}
|
||||||
OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
|
OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
|
||||||
OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}}
|
OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}}
|
||||||
|
|
||||||
R2_BUCKET_NAME=${{project.R2_BUCKET_NAME}}
|
|
||||||
R2_REGION=${{project.R2_REGION}}
|
|
||||||
R2_ENDPOINT=${{project.R2_ENDPOINT}}
|
|
||||||
R2_ACCESS_KEY=${{project.R2_ACCESS_KEY}}
|
|
||||||
R2_SECRET_KEY=${{project.R2_SECRET_KEY}}
|
|
||||||
R2_PUBLIC_URL=${{project.R2_PUBLIC_URL}}
|
|
||||||
|
|
||||||
MAX_FILE_SIZE=${{project.MAX_FILE_SIZE}}
|
|
||||||
ALLOWED_MIME_TYPES=${{project.ALLOWED_MIME_TYPES}}
|
|
||||||
ALLOWED_EXTENSIONS=${{project.ALLOWED_EXTENSIONS}}
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ This document defines the laws, principles, and rule sets that govern this codeb
|
|||||||
3. **No running scripts** — Do not run build, dev, test, or migrate scripts unless explicitly approved.
|
3. **No running scripts** — Do not run build, dev, test, or migrate scripts unless explicitly approved.
|
||||||
4. **No touching migration files** — Do not mess with the `migrations` sql dir, as those are generated manually via drizzle orm
|
4. **No touching migration files** — Do not mess with the `migrations` sql dir, as those are generated manually via drizzle orm
|
||||||
|
|
||||||
|
5. **Log meaningful changes** — After completing any meaningful change or activity, append a numbered entry to `memory.log.md` summarizing what was done. This keeps context across sessions.
|
||||||
|
|
||||||
More rules are only to be added by the human, in case such a suggestion becomes viable.
|
More rules are only to be added by the human, in case such a suggestion becomes viable.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -21,7 +23,7 @@ More rules are only to be added by the human, in case such a suggestion becomes
|
|||||||
|
|
||||||
- **Monorepo**: Turborepo + pnpm
|
- **Monorepo**: Turborepo + pnpm
|
||||||
- **Language**: TypeScript everywhere, Node >= 24
|
- **Language**: TypeScript everywhere, Node >= 24
|
||||||
- **Apps**: `@apps/main` (SvelteKit), `@apps/front` (Hono), `@apps/orchestrator` (Hono)
|
- **Apps**: `@apps/main` (SvelteKit), `@apps/frontend` (Hono), `@apps/orchestrator` (Hono)
|
||||||
- **Packages**: `@pkg/logic`, `@pkg/db`, `@pkg/logger`, `@pkg/result`, `@pkg/keystore`, `@pkg/settings`
|
- **Packages**: `@pkg/logic`, `@pkg/db`, `@pkg/logger`, `@pkg/result`, `@pkg/keystore`, `@pkg/settings`
|
||||||
- **DB**: PostgreSQL via Drizzle ORM; Redis (Valkey) via `@pkg/keystore`
|
- **DB**: PostgreSQL via Drizzle ORM; Redis (Valkey) via `@pkg/keystore`
|
||||||
|
|
||||||
@@ -32,11 +34,13 @@ More rules are only to be added by the human, in case such a suggestion becomes
|
|||||||
All domain logic lives in `@pkg/logic` under `packages/logic/domains/<domain>/` with four files: `data.ts`, `repository.ts`, `controller.ts`, `errors.ts`. Mirror this exactly when adding a domain.
|
All domain logic lives in `@pkg/logic` under `packages/logic/domains/<domain>/` with four files: `data.ts`, `repository.ts`, `controller.ts`, `errors.ts`. Mirror this exactly when adding a domain.
|
||||||
|
|
||||||
**Path aliases** (logic package only):
|
**Path aliases** (logic package only):
|
||||||
|
|
||||||
- `@/*` → `./*` · `@domains/*` → `./domains/*` · `@core/*` → `./core/*`
|
- `@/*` → `./*` · `@domains/*` → `./domains/*` · `@core/*` → `./core/*`
|
||||||
|
|
||||||
**FlowExecCtx** (`fctx`) — passed into every domain operation for tracing:
|
**FlowExecCtx** (`fctx`) — passed into every domain operation for tracing:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string; };
|
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string };
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -8,12 +8,13 @@ Currently in alpha. Greenfield. Subject to change.
|
|||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. Admin generates a unique link and assigns it to a user (or a slot).
|
1. Admin generates a unique link and assigns it to a specific Android app on a specific device.
|
||||||
2. User opens that link in their browser — served by `apps/front`.
|
2. User opens that link in their browser — served by `apps/frontend`.
|
||||||
3. User is shown a loading screen for good UX purposes
|
3. During the loading flow, `apps/frontend` validates the link and asks `apps/orchestrator` to reset the assigned Android session and launch the leased app.
|
||||||
4. User is prompted to install the PWA.
|
4. If that device is already in use by another end user, the link fails instead of taking over the session.
|
||||||
5. User opens the PWA — they are routed into a live stream of their assigned Android instance.
|
5. User is prompted to install the PWA.
|
||||||
6. Admin manages the entire fleet from `apps/main` (the dashboard), which communicates with `apps/orchestrator` running on each VPS to control Docker-Android containers.
|
6. User opens the PWA — they are routed into a live stream of their assigned Android app session.
|
||||||
|
7. Admin manages the entire fleet from `apps/main` (the dashboard), which communicates with `apps/orchestrator` running on each VPS to control Docker-Android containers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,40 +35,42 @@ Currently in alpha. Greenfield. Subject to change.
|
|||||||
|
|
||||||
### Device Management (Orchestrator + Admin)
|
### Device Management (Orchestrator + Admin)
|
||||||
|
|
||||||
- [ ] Device schema — DB model for a device (host VPS, container ID, status, assigned session, etc.)
|
- [x] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.)
|
||||||
- [ ] Device domain in `@pkg/logic` — controller + repository + errors
|
- [x] Device domain in `@pkg/logic` — controller + repository + errors
|
||||||
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
|
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
|
||||||
- [ ] `POST /devices/:id/start` — start a Docker-Android container
|
- [ ] `POST /devices/:id/start` — start a Docker-Android container
|
||||||
- [ ] `POST /devices/:id/stop` — stop a container
|
- [ ] `POST /devices/:id/stop` — stop a container
|
||||||
- [ ] `POST /devices/:id/restart` — restart a container
|
- [ ] `POST /devices/:id/restart` — restart a container
|
||||||
- [ ] `GET /devices` — list all devices and their current status
|
- [ ] `GET /devices` — list all devices and their current status
|
||||||
- [ ] `GET /devices/:id` — page to view the device in more detail (info, live stream feed with ws-scrcpy)
|
- [ ] `GET /devices/:id` — page to view the device in more detail (info, live stream feed with ws-scrcpy)
|
||||||
- [ ] Device allocation logic — mark a device as in-use for a user session
|
- [ ] Device allocation logic — atomically mark a device as `inUse` when a validated link starts a session
|
||||||
- [ ] Device release logic — free up a device when a session ends
|
- [ ] Device release logic — clear `inUse` when a session ends or fails during setup
|
||||||
- [ ] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
|
- [x] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
|
||||||
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
|
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
|
||||||
|
|
||||||
### Link Management (Admin + Front App)
|
### Link Management (Admin + Front App)
|
||||||
|
|
||||||
- [ ] Link schema — DB model (unique token, expiry, status, linked device ID)
|
- [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
|
||||||
- [ ] Link domain in `@pkg/logic` — controller + repository + errors
|
- [x] Link domain in `@pkg/logic` — controller + repository + errors
|
||||||
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device, revoke, delete
|
- [x] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete
|
||||||
- [ ] `apps/front`: validate incoming link token on request
|
- [ ] `apps/frontend`: validate incoming link token on request
|
||||||
- [ ] `apps/front`: return appropriate error page for invalid/expired/revoked links
|
- [ ] `apps/frontend`: during loading, reject the link if the assigned device is already `inUse`
|
||||||
|
- [ ] `apps/frontend`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session
|
||||||
|
- [ ] `apps/frontend`: return appropriate error page for invalid/expired/revoked links
|
||||||
- [ ] Front: keep on checking for link status change, if it gets revoked, we cutoff the connection
|
- [ ] Front: keep on checking for link status change, if it gets revoked, we cutoff the connection
|
||||||
|
|
||||||
### PWA & User Session Flow (`apps/front`)
|
### PWA & User Session Flow (`apps/frontend`)
|
||||||
|
|
||||||
- [ ] `apps/front`: serve static PWA shell (HTML + manifest + service worker)
|
- [ ] `apps/frontend`: serve static PWA shell (HTML + manifest + service worker)
|
||||||
- [ ] `apps/front`: wait/loading page — just for show with a 3-5s duration
|
- [ ] `apps/frontend`: wait/loading page — just for show with a 3-5s duration
|
||||||
- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling)
|
- [ ] `apps/frontend`: PWA install prompt flow (beforeinstallprompt handling)
|
||||||
- [ ] `apps/front`: session binding — tie the PWA launch to the user's allocated device
|
- [ ] `apps/frontend`: session binding — tie the PWA launch to the user's allocated device
|
||||||
- [ ] `apps/front`: route/proxy authenticated PWA requests to the Android instance stream
|
- [ ] `apps/frontend`: route/proxy authenticated PWA requests to the Android instance stream
|
||||||
|
|
||||||
### Android Streaming (scrcpy + ws-scrcpy)
|
### Android Streaming (scrcpy + ws-scrcpy)
|
||||||
|
|
||||||
- [ ] Docker-Android image setup and validation on VPS
|
- [x] Docker-Android image setup and validation on VPS
|
||||||
- [ ] ws-scrcpy WebSocket server running per container, exposed via orchestrator
|
- [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator
|
||||||
- [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser
|
- [ ] `apps/frontend`: scrcpy client embedded in PWA — renders the Android stream in browser
|
||||||
- [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
|
- [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
|
||||||
- [ ] Session timeout + stream teardown on inactivity
|
- [ ] Session timeout + stream teardown on inactivity
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Proxy Test — Bun Reverse Proxy Server
|
|
||||||
*
|
|
||||||
* Sits on port 3000 (what ngrok tunnels) and:
|
|
||||||
* GET / -> serves our clean mobile viewer (view.html)
|
|
||||||
* Everything else -> reverse-proxied to ws-scrcpy on port 8000
|
|
||||||
*
|
|
||||||
* WebSocket upgrades are proxied transparently so the ws-scrcpy
|
|
||||||
* player running inside our iframe can talk to the scrcpy server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const WS_SCRCPY_ORIGIN = "http://localhost:8000";
|
|
||||||
const PROXY_PORT = 3000;
|
|
||||||
|
|
||||||
const viewHtml = await Bun.file(
|
|
||||||
new URL("./view.html", import.meta.url).pathname,
|
|
||||||
).text();
|
|
||||||
|
|
||||||
const server = Bun.serve({
|
|
||||||
port: PROXY_PORT,
|
|
||||||
hostname: "0.0.0.0",
|
|
||||||
|
|
||||||
async fetch(req, server) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
|
|
||||||
// Serve our clean mobile viewer at root only
|
|
||||||
// (the iframe loads ws-scrcpy's own page via /?scrcpy=1)
|
|
||||||
if (
|
|
||||||
(url.pathname === "/" || url.pathname === "/index.html") &&
|
|
||||||
!url.searchParams.has("scrcpy")
|
|
||||||
) {
|
|
||||||
return new Response(viewHtml, {
|
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade WebSocket connections — proxy to ws-scrcpy
|
|
||||||
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
||||||
const targetUrl = `${WS_SCRCPY_ORIGIN.replace("http", "ws")}${url.pathname}${url.search}`;
|
|
||||||
|
|
||||||
// Use Bun's WebSocket upgrade to establish a client-side WS to ws-scrcpy
|
|
||||||
// and bridge the two connections
|
|
||||||
const success = server.upgrade(req, {
|
|
||||||
data: { targetUrl },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) return undefined;
|
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy all other HTTP requests to ws-scrcpy
|
|
||||||
const targetUrl = `${WS_SCRCPY_ORIGIN}${url.pathname}${url.search}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proxyRes = await fetch(targetUrl, {
|
|
||||||
method: req.method,
|
|
||||||
headers: req.headers,
|
|
||||||
body:
|
|
||||||
req.method !== "GET" && req.method !== "HEAD"
|
|
||||||
? req.body
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clone response with CORS headers stripped / adjusted
|
|
||||||
const headers = new Headers(proxyRes.headers);
|
|
||||||
headers.delete("content-encoding"); // Bun handles this
|
|
||||||
|
|
||||||
return new Response(proxyRes.body, {
|
|
||||||
status: proxyRes.status,
|
|
||||||
statusText: proxyRes.statusText,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Proxy error for ${url.pathname}:`, err);
|
|
||||||
return new Response(
|
|
||||||
"ws-scrcpy not reachable — is it running on port 8000?",
|
|
||||||
{
|
|
||||||
status: 502,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
websocket: {
|
|
||||||
async open(ws) {
|
|
||||||
const { targetUrl } = ws.data as { targetUrl: string };
|
|
||||||
|
|
||||||
// Open a WebSocket connection to ws-scrcpy
|
|
||||||
const upstream = new WebSocket(targetUrl);
|
|
||||||
|
|
||||||
// Store upstream reference on ws data for cleanup
|
|
||||||
(ws.data as any).upstream = upstream;
|
|
||||||
|
|
||||||
upstream.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
upstream.onopen = () => {
|
|
||||||
// Connection established, nothing extra needed
|
|
||||||
};
|
|
||||||
|
|
||||||
upstream.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
|
||||||
ws.sendBinary(new Uint8Array(event.data));
|
|
||||||
} else {
|
|
||||||
ws.sendText(event.data);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Client disconnected
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
upstream.onclose = () => {
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
upstream.onerror = (err) => {
|
|
||||||
console.error("Upstream WS error:", err);
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
message(ws, message) {
|
|
||||||
const upstream = (ws.data as any).upstream as WebSocket | undefined;
|
|
||||||
if (!upstream || upstream.readyState !== WebSocket.OPEN) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof message === "string") {
|
|
||||||
upstream.send(message);
|
|
||||||
} else {
|
|
||||||
upstream.send(message);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Upstream disconnected
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
close(ws) {
|
|
||||||
const upstream = (ws.data as any).upstream as WebSocket | undefined;
|
|
||||||
if (upstream && upstream.readyState === WebSocket.OPEN) {
|
|
||||||
upstream.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n Mobile Proxy running on http://localhost:${server.port}`);
|
|
||||||
console.log(` Proxying to ws-scrcpy at ${WS_SCRCPY_ORIGIN}`);
|
|
||||||
console.log(
|
|
||||||
`\n Point ngrok at port ${server.port}, then open the ngrok URL on the user's phone.\n`,
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@apps/front",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "PORT=3000 tsx watch src/index.ts",
|
|
||||||
"build": "tsc",
|
|
||||||
"prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@hono/node-server": "^1.19.9",
|
|
||||||
"@opentelemetry/api": "^1.9.0",
|
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
|
|
||||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
|
|
||||||
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
|
|
||||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
|
|
||||||
"@opentelemetry/sdk-logs": "^0.212.0",
|
|
||||||
"@opentelemetry/sdk-metrics": "^2.1.0",
|
|
||||||
"@opentelemetry/sdk-node": "^0.212.0",
|
|
||||||
"@pkg/db": "workspace:*",
|
|
||||||
"@pkg/logger": "workspace:*",
|
|
||||||
"@pkg/logic": "workspace:*",
|
|
||||||
"@pkg/result": "workspace:*",
|
|
||||||
"@pkg/settings": "workspace:*",
|
|
||||||
"hono": "^4.12.8",
|
|
||||||
"import-in-the-middle": "^3.0.0",
|
|
||||||
"valibot": "^1.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^25.3.2",
|
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import "./instrumentation.js";
|
|
||||||
|
|
||||||
import { createHttpTelemetryMiddleware } from "@pkg/logic/core/http.telemetry";
|
|
||||||
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
|
|
||||||
import { logDomainEvent } from "@pkg/logger";
|
|
||||||
import { serve } from "@hono/node-server";
|
|
||||||
import { settings } from "@pkg/settings";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
const app = new Hono().use("*", createHttpTelemetryMiddleware("front"));
|
|
||||||
|
|
||||||
const host = process.env.HOST || "0.0.0.0";
|
|
||||||
const port = Number(process.env.PORT || "3000");
|
|
||||||
|
|
||||||
function normalizeBaseUrl(url: string): string {
|
|
||||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFlowExecCtx(): FlowExecCtx {
|
|
||||||
return { flowId: randomUUID() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientDownloadedApkName(): string {
|
|
||||||
const filename = settings.clientDownloadedApkName.trim();
|
|
||||||
return filename.toLowerCase().endsWith(".apk")
|
|
||||||
? filename
|
|
||||||
: `${filename}.apk`;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get("/health", (c) => {
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/ping", (c) => {
|
|
||||||
return c.text("pong");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/downloads/file/:buildId", async (c) => {
|
|
||||||
const fctx = buildFlowExecCtx();
|
|
||||||
const buildId = c.req.param("buildId");
|
|
||||||
|
|
||||||
logDomainEvent({
|
|
||||||
event: "processor.apk_download.started",
|
|
||||||
fctx,
|
|
||||||
meta: { buildId },
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildResult = await mobileBuildController.validateActiveBuildId(
|
|
||||||
fctx,
|
|
||||||
buildId,
|
|
||||||
);
|
|
||||||
if (buildResult.isErr()) {
|
|
||||||
logDomainEvent({
|
|
||||||
level: "warn",
|
|
||||||
event: "processor.apk_download.rejected",
|
|
||||||
fctx,
|
|
||||||
error: buildResult.error,
|
|
||||||
meta: { buildId },
|
|
||||||
});
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
data: null,
|
|
||||||
error: { ...buildResult.error, flowId: fctx.flowId },
|
|
||||||
},
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const build = buildResult.value;
|
|
||||||
if (!build.apkAssetPath) {
|
|
||||||
logDomainEvent({
|
|
||||||
level: "warn",
|
|
||||||
event: "processor.apk_download.missing_artifact",
|
|
||||||
fctx,
|
|
||||||
meta: { buildId },
|
|
||||||
});
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
data: null,
|
|
||||||
error: {
|
|
||||||
flowId: fctx.flowId,
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "APK not available",
|
|
||||||
description: "This build does not have a generated APK yet",
|
|
||||||
detail: `buildId=${buildId}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetUrl = `${normalizeBaseUrl(settings.appBuilderApiUrl)}${build.apkAssetPath}`;
|
|
||||||
const assetResponse = await fetch(assetUrl);
|
|
||||||
|
|
||||||
if (!assetResponse.ok || !assetResponse.body) {
|
|
||||||
logDomainEvent({
|
|
||||||
level: "error",
|
|
||||||
event: "processor.apk_download.fetch_failed",
|
|
||||||
fctx,
|
|
||||||
meta: {
|
|
||||||
buildId,
|
|
||||||
assetUrl,
|
|
||||||
status: assetResponse.status,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
data: null,
|
|
||||||
error: {
|
|
||||||
flowId: fctx.flowId,
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Failed to fetch APK artifact",
|
|
||||||
description: "Please try again later",
|
|
||||||
detail: `assetUrl=${assetUrl} status=${assetResponse.status}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
502,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logDomainEvent({
|
|
||||||
event: "processor.apk_download.succeeded",
|
|
||||||
fctx,
|
|
||||||
meta: {
|
|
||||||
buildId,
|
|
||||||
assetUrl,
|
|
||||||
downloadName: getClientDownloadedApkName(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(assetResponse.body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"content-type":
|
|
||||||
assetResponse.headers.get("content-type") ||
|
|
||||||
"application/vnd.android.package-archive",
|
|
||||||
"content-disposition": `attachment; filename="${getClientDownloadedApkName()}"`,
|
|
||||||
"cache-control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
serve(
|
|
||||||
{
|
|
||||||
fetch: app.fetch,
|
|
||||||
port,
|
|
||||||
hostname: host,
|
|
||||||
},
|
|
||||||
(info) => {
|
|
||||||
console.log(`Server is running on http://${host}:${info.port}`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"verbatimModuleSyntax": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"types": [
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@pkg/logic": ["../../packages/logic"],
|
|
||||||
"@pkg/logic/*": ["../../packages/logic/*"],
|
|
||||||
"@pkg/db": ["../../packages/db"],
|
|
||||||
"@pkg/db/*": ["../../packages/db/*"],
|
|
||||||
"@pkg/logger": ["../../packages/logger"],
|
|
||||||
"@pkg/logger/*": ["../../packages/logger/*"],
|
|
||||||
"@pkg/result": ["../../packages/result"],
|
|
||||||
"@pkg/result/*": ["../../packages/result/*"],
|
|
||||||
"@pkg/settings": ["../../packages/settings"],
|
|
||||||
"@pkg/settings/*": ["../../packages/settings/*"],
|
|
||||||
"@/*": ["../../packages/logic/*"],
|
|
||||||
"@core/*": ["../../packages/logic/core/*"],
|
|
||||||
"@domains/*": ["../../packages/logic/domains/*"]
|
|
||||||
},
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "hono/jsx",
|
|
||||||
"outDir": "./dist"
|
|
||||||
},
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
|
||||||
/>
|
|
||||||
<!-- iOS full-screen web app -->
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta
|
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
|
||||||
content="black-translucent"
|
|
||||||
/>
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
<title>Loading...</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body,
|
|
||||||
html {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #000;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
touch-action: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading overlay ─────────────────────────── */
|
|
||||||
#overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #000;
|
|
||||||
color: #999;
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
z-index: 200;
|
|
||||||
transition: opacity 0.4s ease;
|
|
||||||
}
|
|
||||||
#overlay.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: 2px solid #333;
|
|
||||||
border-top-color: #999;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── ws-scrcpy iframe ────────────────────────── */
|
|
||||||
#scrcpy-frame {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="overlay">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Connecting...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe id="scrcpy-frame" src="" allow="autoplay"></iframe>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const iframe = document.getElementById("scrcpy-frame");
|
|
||||||
const overlay = document.getElementById("overlay");
|
|
||||||
|
|
||||||
// We need the device UDID to build the direct-stream URL.
|
|
||||||
// Strategy: first, load the ws-scrcpy device list page silently
|
|
||||||
// and scrape the first available device, then redirect the iframe
|
|
||||||
// to the stream URL for that device.
|
|
||||||
|
|
||||||
// Phase 1: load device list from ws-scrcpy (proxied through us)
|
|
||||||
async function getFirstDevice() {
|
|
||||||
// ws-scrcpy exposes device info via its tracker WebSocket,
|
|
||||||
// but the simplest approach is to fetch the page HTML and
|
|
||||||
// parse the device list. However, ws-scrcpy is an SPA that
|
|
||||||
// builds the list client-side. So instead we'll use a short
|
|
||||||
// poll: load ws-scrcpy in the iframe with the device list,
|
|
||||||
// wait for a device to appear, grab its UDID, then switch
|
|
||||||
// to the stream view.
|
|
||||||
|
|
||||||
// Actually, even simpler: ws-scrcpy also has a device tracker
|
|
||||||
// that sends device info over WebSocket. Let's just load the
|
|
||||||
// ws-scrcpy index in the iframe, wait for the content, then
|
|
||||||
// inject CSS + auto-click the first stream player link.
|
|
||||||
|
|
||||||
// Load ws-scrcpy's index (proxied, so same-origin)
|
|
||||||
// The ?scrcpy=1 param tells our Bun proxy to pass through
|
|
||||||
// to ws-scrcpy instead of serving view.html again.
|
|
||||||
iframe.src = "/?scrcpy=1";
|
|
||||||
}
|
|
||||||
|
|
||||||
const HIDE_CSS = `
|
|
||||||
/* Hide device list table and all non-video UI */
|
|
||||||
#devices,
|
|
||||||
.table-wrapper,
|
|
||||||
.tracker-name,
|
|
||||||
.control-buttons-list,
|
|
||||||
.control-wrapper,
|
|
||||||
.more-box {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make video fill the entire viewport */
|
|
||||||
.device-view {
|
|
||||||
position: fixed !important;
|
|
||||||
inset: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
background: #000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
position: absolute !important;
|
|
||||||
inset: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-layer,
|
|
||||||
.touch-layer {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 100% !important;
|
|
||||||
object-fit: contain !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
background: #000 !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function injectCSS(doc) {
|
|
||||||
if (doc.querySelector("#iotam-hide-css")) return;
|
|
||||||
const style = doc.createElement("style");
|
|
||||||
style.id = "iotam-hide-css";
|
|
||||||
style.textContent = HIDE_CSS;
|
|
||||||
doc.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: once iframe loads, inject CSS to hide the device list
|
|
||||||
// UI and auto-click the first available stream link.
|
|
||||||
iframe.addEventListener("load", function onLoad() {
|
|
||||||
try {
|
|
||||||
const doc =
|
|
||||||
iframe.contentDocument || iframe.contentWindow.document;
|
|
||||||
if (!doc) return;
|
|
||||||
|
|
||||||
injectCSS(doc);
|
|
||||||
|
|
||||||
// Re-inject CSS whenever ws-scrcpy rebuilds the DOM
|
|
||||||
// (e.g., when navigating from device list to stream via hash)
|
|
||||||
const observer = new MutationObserver(() => injectCSS(doc));
|
|
||||||
observer.observe(doc.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now we need to auto-navigate to the first device's stream.
|
|
||||||
// The device list is built async via WebSocket, so we poll
|
|
||||||
// for a device link to appear and click it.
|
|
||||||
pollForDevice(doc);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Cannot access iframe (cross-origin?):", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function pollForDevice(doc) {
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 60; // 30 seconds
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
attempts++;
|
|
||||||
|
|
||||||
// Look for stream player links in the device list
|
|
||||||
// ws-scrcpy renders links with player names as link text
|
|
||||||
const links = doc.querySelectorAll("a");
|
|
||||||
let streamLink = null;
|
|
||||||
|
|
||||||
for (const link of links) {
|
|
||||||
const href = link.getAttribute("href") || "";
|
|
||||||
const text = (link.textContent || "").toLowerCase();
|
|
||||||
// Look for player links — they contain "action=stream"
|
|
||||||
// or player names like "mse", "webcodecs", etc.
|
|
||||||
if (
|
|
||||||
href.includes("action=stream") ||
|
|
||||||
text === "mse" ||
|
|
||||||
text === "webcodecs" ||
|
|
||||||
text === "tinyh264" ||
|
|
||||||
text === "broadway"
|
|
||||||
) {
|
|
||||||
streamLink = link;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (streamLink) {
|
|
||||||
clearInterval(interval);
|
|
||||||
console.log(
|
|
||||||
"Found device stream link, clicking:",
|
|
||||||
streamLink.href,
|
|
||||||
);
|
|
||||||
streamLink.click();
|
|
||||||
|
|
||||||
// Wait for stream to start rendering, then hide overlay
|
|
||||||
waitForVideo(doc);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts >= maxAttempts) {
|
|
||||||
clearInterval(interval);
|
|
||||||
overlay.querySelector("span").textContent =
|
|
||||||
"No device found. Make sure your phone is connected via USB.";
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForVideo(doc) {
|
|
||||||
let checks = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
checks++;
|
|
||||||
const canvas = doc.querySelector("canvas");
|
|
||||||
const video = doc.querySelector("video");
|
|
||||||
|
|
||||||
if (canvas || video) {
|
|
||||||
clearInterval(interval);
|
|
||||||
// Give a moment for first frame to render
|
|
||||||
setTimeout(() => {
|
|
||||||
overlay.classList.add("hidden");
|
|
||||||
document.title = "\u200B"; // Zero-width space — blank title
|
|
||||||
}, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checks > 30) {
|
|
||||||
clearInterval(interval);
|
|
||||||
overlay.classList.add("hidden");
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent default touch behaviors that interfere
|
|
||||||
document.addEventListener("touchmove", (e) => e.preventDefault(), {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
document.addEventListener(
|
|
||||||
"touchstart",
|
|
||||||
(e) => {
|
|
||||||
if (e.touches.length > 1) e.preventDefault();
|
|
||||||
},
|
|
||||||
{ passive: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start
|
|
||||||
getFirstDevice();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
23
apps/frontend/.gitignore
vendored
Normal file
23
apps/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
apps/frontend/.npmrc
Normal file
1
apps/frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
9
apps/frontend/.prettierignore
Normal file
9
apps/frontend/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
16
apps/frontend/components.json
Normal file
16
apps/frontend/components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/routes/layout.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"
|
||||||
|
}
|
||||||
74
apps/frontend/package.json
Normal file
74
apps/frontend/package.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "@apps/frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 5174",
|
||||||
|
"build": "NODE_ENV=build vite build",
|
||||||
|
"prod": "HOST=0.0.0.0 PORT=3000 node ./build/index.js",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check .",
|
||||||
|
"test:unit": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
|
||||||
|
"@opentelemetry/exporter-logs-otlp-proto": "^0.212.0",
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-proto": "^0.212.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.212.0",
|
||||||
|
"@opentelemetry/sdk-logs": "^0.212.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.212.0",
|
||||||
|
"@pkg/db": "workspace:*",
|
||||||
|
"@pkg/keystore": "workspace:*",
|
||||||
|
"@pkg/logger": "workspace:*",
|
||||||
|
"@pkg/logic": "workspace:*",
|
||||||
|
"@pkg/result": "workspace:*",
|
||||||
|
"@pkg/settings": "workspace:*",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"import-in-the-middle": "^3.0.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"neverthrow": "^8.2.0",
|
||||||
|
"valibot": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/json": "^2.2.434",
|
||||||
|
"@internationalized/date": "^3.10.0",
|
||||||
|
"@lucide/svelte": "^0.561.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
|
"@sveltejs/kit": "^2.53.4",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/table-core": "^8.21.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"bits-ui": "^2.14.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"embla-carousel-svelte": "^8.6.0",
|
||||||
|
"formsnap": "^2.0.1",
|
||||||
|
"layerchart": "2.0.0-next.43",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"paneforge": "^1.0.2",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"svelte": "^5.53.6",
|
||||||
|
"svelte-check": "^4.4.4",
|
||||||
|
"svelte-sonner": "^1.0.7",
|
||||||
|
"sveltekit-superforms": "^2.30.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"unplugin-icons": "^23.0.1",
|
||||||
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
|
"vite": "^7.2.6",
|
||||||
|
"vitest": "^4.0.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/frontend/src/app.d.ts
vendored
Normal file
20
apps/frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Session, User } from "@pkg/logic/domains/user/data";
|
||||||
|
import "unplugin-icons/types/svelte";
|
||||||
|
|
||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
interface Locals {
|
||||||
|
flowId?: string;
|
||||||
|
session?: Session;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
11
apps/frontend/src/app.html
Normal file
11
apps/frontend/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
apps/frontend/src/demo.spec.ts
Normal file
7
apps/frontend/src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('sum test', () => {
|
||||||
|
it('adds 1 + 2 to equal 3', () => {
|
||||||
|
expect(1 + 2).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/frontend/src/hooks.server.ts
Normal file
15
apps/frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Handle, HandleServerError } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const handleError: HandleServerError = async ({ error, event }) => {
|
||||||
|
console.log("[-] Running error middleware for : ", event.url.pathname);
|
||||||
|
console.log(error);
|
||||||
|
return { message: (error as Error).message ?? "Internal Server Error" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const middleware: Handle = async ({ event, resolve }) => {
|
||||||
|
event.locals.flowId ||= crypto.randomUUID();
|
||||||
|
console.log("[+] Running middleware for : ", event.url.pathname);
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handle = middleware;
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
|
|
||||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
|
||||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
|
||||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
|
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
|
||||||
|
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
||||||
import { createAddHookMessageChannel } from "import-in-the-middle";
|
import { createAddHookMessageChannel } from "import-in-the-middle";
|
||||||
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
|
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
|
||||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||||
@@ -12,23 +10,9 @@ import { register } from "node:module";
|
|||||||
const { registerOptions } = createAddHookMessageChannel();
|
const { registerOptions } = createAddHookMessageChannel();
|
||||||
register("import-in-the-middle/hook.mjs", import.meta.url, registerOptions);
|
register("import-in-the-middle/hook.mjs", import.meta.url, registerOptions);
|
||||||
|
|
||||||
const normalizedEndpoint = settings.otelExporterOtlpHttpEndpoint.startsWith(
|
|
||||||
"http",
|
|
||||||
)
|
|
||||||
? settings.otelExporterOtlpHttpEndpoint
|
|
||||||
: `http://${settings.otelExporterOtlpHttpEndpoint}`;
|
|
||||||
|
|
||||||
if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
||||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = normalizedEndpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sdk = new NodeSDK({
|
const sdk = new NodeSDK({
|
||||||
serviceName: `${settings.otelServiceName}-processor`,
|
serviceName: settings.otelServiceName || settings.appName,
|
||||||
traceExporter: new OTLPTraceExporter(),
|
traceExporter: new OTLPTraceExporter(),
|
||||||
metricReader: new PeriodicExportingMetricReader({
|
|
||||||
exporter: new OTLPMetricExporter(),
|
|
||||||
exportIntervalMillis: 10_000,
|
|
||||||
}),
|
|
||||||
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
|
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
|
||||||
instrumentations: [
|
instrumentations: [
|
||||||
getNodeAutoInstrumentations({
|
getNodeAutoInstrumentations({
|
||||||
@@ -41,10 +25,3 @@ const sdk = new NodeSDK({
|
|||||||
});
|
});
|
||||||
|
|
||||||
sdk.start();
|
sdk.start();
|
||||||
|
|
||||||
const shutdown = () => {
|
|
||||||
void sdk.shutdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
35
apps/frontend/src/lib/components/app-sidebar.svelte
Normal file
35
apps/frontend/src/lib/components/app-sidebar.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import BriefcaseIcon from "@lucide/svelte/icons/briefcase";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import NavMain from "$lib/components/nav-main.svelte";
|
||||||
|
import NavUser from "$lib/components/nav-user.svelte";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import { mainNavTree } from "$lib/core/constants";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
const data = $state({
|
||||||
|
teams: [{ name: "Personal", logo: BriefcaseIcon, plan: "Standard" }],
|
||||||
|
navItems: mainNavTree,
|
||||||
|
});
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
collapsible = "icon",
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Root bind:ref {collapsible} {...restProps}>
|
||||||
|
<!-- <Sidebar.Header>
|
||||||
|
<TeamSwitcher teams={data.teams} />
|
||||||
|
</Sidebar.Header> -->
|
||||||
|
<Sidebar.Content class="pt-2">
|
||||||
|
<NavMain items={data.navItems} />
|
||||||
|
</Sidebar.Content>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<NavUser />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
<Sidebar.Rail />
|
||||||
|
</Sidebar.Root>
|
||||||
17
apps/frontend/src/lib/components/atoms/button-text.svelte
Normal file
17
apps/frontend/src/lib/components/atoms/button-text.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LoaderCircleIcon from "~icons/lucide/loader-circle";
|
||||||
|
import Icon from "./icon.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
loading,
|
||||||
|
loadingText,
|
||||||
|
text,
|
||||||
|
}: { loading: boolean; loadingText: string; text: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<Icon icon={LoaderCircleIcon} cls="h-5 w-auto animate-spin" />
|
||||||
|
{loadingText}
|
||||||
|
{:else}
|
||||||
|
{text}
|
||||||
|
{/if}
|
||||||
11
apps/frontend/src/lib/components/atoms/icon.svelte
Normal file
11
apps/frontend/src/lib/components/atoms/icon.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
icon: Icon,
|
||||||
|
cls,
|
||||||
|
}: {
|
||||||
|
icon: any;
|
||||||
|
cls?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Icon class={cls} />
|
||||||
61
apps/frontend/src/lib/components/atoms/title.svelte
Normal file
61
apps/frontend/src/lib/components/atoms/title.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
type TitleSize = "h1" | "h2" | "h3" | "h4" | "h5" | "p";
|
||||||
|
type TitleFontWeight = "bold" | "semibold" | "normal" | "medium";
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
theme: "text-primary",
|
||||||
|
white: "text-white",
|
||||||
|
black: "text-black",
|
||||||
|
primaryLight: "text-primary/80",
|
||||||
|
};
|
||||||
|
|
||||||
|
const weights = {
|
||||||
|
bold: "font-bold",
|
||||||
|
semibold: "font-semibold",
|
||||||
|
medium: "font-medium",
|
||||||
|
normal: "font-normal",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
h1: "text-6xl md:text-7xl",
|
||||||
|
h2: "text-5xl lg:text-6xl",
|
||||||
|
h3: "text-4xl lg:text-5xl",
|
||||||
|
h4: "text-2xl lg:text-3xl",
|
||||||
|
h5: "text-xl lg:text-2xl",
|
||||||
|
p: "text-lg lg:text-xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
size = "h2",
|
||||||
|
weight = "semibold",
|
||||||
|
capitalize = true,
|
||||||
|
color = "theme",
|
||||||
|
center = false,
|
||||||
|
id = undefined,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
size?: TitleSize;
|
||||||
|
weight?: TitleFontWeight;
|
||||||
|
capitalize?: boolean;
|
||||||
|
color?: keyof typeof colors;
|
||||||
|
center?: boolean;
|
||||||
|
id?: string;
|
||||||
|
children?: any;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={size}
|
||||||
|
class={cn(
|
||||||
|
sizes[size] || sizes.p,
|
||||||
|
weights[weight ?? "bold"],
|
||||||
|
capitalize && "capitalize",
|
||||||
|
colors[color],
|
||||||
|
center && "text-center",
|
||||||
|
)}
|
||||||
|
{id}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</svelte:element>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let { cls, children }: { cls: string; children: any } = $props();
|
||||||
|
|
||||||
|
let clsss = cn("mx-auto h-full w-full max-w-screen-xl", cls);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={clsss}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
81
apps/frontend/src/lib/components/nav-main.svelte
Normal file
81
apps/frontend/src/lib/components/nav-main.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import type { AppSidebarItem } from "$lib/core/constants";
|
||||||
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import Icon from "./atoms/icon.svelte";
|
||||||
|
|
||||||
|
let { items }: { items: AppSidebarItem[] } = $props();
|
||||||
|
|
||||||
|
const sidebar = Sidebar.useSidebar();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Group>
|
||||||
|
<Sidebar.Menu>
|
||||||
|
{#each items as mainItem (mainItem.title)}
|
||||||
|
<Collapsible.Root
|
||||||
|
open={mainItem.isActive}
|
||||||
|
class="group/collapsible"
|
||||||
|
>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<!-- Split the behavior: main button redirects, chevron expands -->
|
||||||
|
<div class="flex w-full items-center">
|
||||||
|
<!-- Main navigation button that redirects -->
|
||||||
|
<Sidebar.MenuButton class="flex-1 pr-1">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href={mainItem.url} {...props}>
|
||||||
|
{#if mainItem.icon}
|
||||||
|
<mainItem.icon />
|
||||||
|
{/if}
|
||||||
|
<span>{mainItem.title}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
|
||||||
|
<!-- Chevron button that only toggles the collapsible content -->
|
||||||
|
{#if mainItem.items && sidebar.open}
|
||||||
|
<Collapsible.Trigger class="p-2">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
class="bg-background hover:bg-accent flex cursor-pointer items-center justify-center rounded-sm p-1"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={ChevronRight}
|
||||||
|
cls="w-4 h-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||||
|
/>
|
||||||
|
<span class="sr-only"
|
||||||
|
>Toggle {mainItem.title} submenu</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible submenu content -->
|
||||||
|
<Collapsible.Content>
|
||||||
|
{#if mainItem.items}
|
||||||
|
<Sidebar.MenuSub>
|
||||||
|
{#each mainItem.items as subItem (subItem.title)}
|
||||||
|
<Sidebar.MenuSubItem>
|
||||||
|
<Sidebar.MenuSubButton>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a
|
||||||
|
href={subItem.url}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuSubButton>
|
||||||
|
</Sidebar.MenuSubItem>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.MenuSub>
|
||||||
|
{/if}
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Collapsible.Root>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.Group>
|
||||||
150
apps/frontend/src/lib/components/nav-user.svelte
Normal file
150
apps/frontend/src/lib/components/nav-user.svelte
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Avatar from "$lib/components/ui/avatar/index.js";
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import { secondaryNavTree } from "$lib/core/constants";
|
||||||
|
import { sessionsVM } from "$lib/domains/account/sessions/sessions.vm.svelte";
|
||||||
|
import { user as userStore } from "$lib/global.stores";
|
||||||
|
import { type User } from "@pkg/logic/domains/user/data";
|
||||||
|
import { resetMode, setMode } from "mode-watcher";
|
||||||
|
import ChevronsUpDown from "~icons/lucide/chevrons-up-down";
|
||||||
|
import LogOut from "~icons/lucide/log-out";
|
||||||
|
import Monitor from "~icons/lucide/monitor";
|
||||||
|
import Moon from "~icons/lucide/moon";
|
||||||
|
import Sun from "~icons/lucide/sun";
|
||||||
|
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
let user = $state({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
image: "",
|
||||||
|
...($userStore || {}),
|
||||||
|
} as any as User);
|
||||||
|
|
||||||
|
userStore.subscribe((value) => {
|
||||||
|
if (value) {
|
||||||
|
user = value;
|
||||||
|
}
|
||||||
|
if (user.name.length < 2) {
|
||||||
|
// @ts-ignore
|
||||||
|
user.name = "User";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$inspect(user);
|
||||||
|
|
||||||
|
async function logoutUser() {
|
||||||
|
await sessionsVM.logout();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuButton
|
||||||
|
size="lg"
|
||||||
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Avatar.Root class="h-8 w-8 rounded-lg">
|
||||||
|
<Avatar.Image src={user.image} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="rounded-lg">
|
||||||
|
{user.name.slice(0, 2).toUpperCase()}
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div
|
||||||
|
class="grid flex-1 text-left text-sm leading-tight"
|
||||||
|
>
|
||||||
|
<span class="truncate font-semibold"
|
||||||
|
>{user.name}</span
|
||||||
|
>
|
||||||
|
<span class="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown class="ml-auto size-4" />
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="w-[var(--bits-dropdown-menu-anchor-width)] min-w-56 rounded-lg"
|
||||||
|
side={sidebar.isMobile ? "bottom" : "right"}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label class="p-0 font-normal">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
|
||||||
|
>
|
||||||
|
<Avatar.Root class="h-8 w-8 rounded-lg">
|
||||||
|
<Avatar.Image src={user.image} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="rounded-lg">
|
||||||
|
{user.name.slice(0, 2).toUpperCase()}
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div
|
||||||
|
class="grid flex-1 text-left text-sm leading-tight"
|
||||||
|
>
|
||||||
|
<span class="truncate font-semibold"
|
||||||
|
>{user.name}</span
|
||||||
|
>
|
||||||
|
<span class="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
{#each secondaryNavTree as item (item.title)}
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
class="flex h-full w-full items-center gap-2"
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Theme switcher section -->
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger>
|
||||||
|
<Sun
|
||||||
|
class="h-4 w-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
|
||||||
|
/>
|
||||||
|
<Moon
|
||||||
|
class="absolute h-4 w-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
|
||||||
|
/>
|
||||||
|
<span>Theme</span>
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent>
|
||||||
|
<DropdownMenu.Item onclick={() => setMode("light")}>
|
||||||
|
<Sun class="mr-2 h-4 w-4" />
|
||||||
|
<span>Light</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onclick={() => setMode("dark")}>
|
||||||
|
<Moon class="mr-2 h-4 w-4" />
|
||||||
|
<span>Dark</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onclick={() => resetMode()}>
|
||||||
|
<Monitor class="mr-2 h-4 w-4" />
|
||||||
|
<span>System</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item onclick={() => logoutUser()}>
|
||||||
|
<LogOut />
|
||||||
|
Log out
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
80
apps/frontend/src/lib/components/team-switcher.svelte
Normal file
80
apps/frontend/src/lib/components/team-switcher.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
|
||||||
|
// This should be `Component` after @lucide/svelte updates types
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let { teams }: { teams: { name: string; logo: any; plan: string }[] } =
|
||||||
|
$props();
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
let activeTeam = $state(teams[0]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuButton
|
||||||
|
{...props}
|
||||||
|
size="lg"
|
||||||
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<activeTeam.logo class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-semibold">
|
||||||
|
{activeTeam.name}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-xs">{activeTeam.plan}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown class="ml-auto" />
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="w-[var(--bits-dropdown-menu-anchor-width)] min-w-56 rounded-lg"
|
||||||
|
align="start"
|
||||||
|
side={sidebar.isMobile ? "bottom" : "right"}
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label class="text-muted-foreground text-xs"
|
||||||
|
>Teams</DropdownMenu.Label
|
||||||
|
>
|
||||||
|
{#each teams as team, index (team.name)}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => (activeTeam = team)}
|
||||||
|
class="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-6 items-center justify-center rounded-sm border"
|
||||||
|
>
|
||||||
|
<team.logo class="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
{team.name}
|
||||||
|
>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item class="gap-2 p-2">
|
||||||
|
<div
|
||||||
|
class="bg-background flex size-6 items-center justify-center rounded-md border"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground font-medium">
|
||||||
|
Coming soon
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="accordion-content"
|
||||||
|
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div class={cn("pt-0 pb-4", className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AccordionPrimitive.ItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
data-slot="accordion-item"
|
||||||
|
class={cn("border-b last:border-b-0", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
level = 3,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||||
|
level?: AccordionPrimitive.HeaderProps["level"];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AccordionPrimitive.Header {level} class="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: AccordionPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:value={value as never}
|
||||||
|
data-slot="accordion"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
16
apps/frontend/src/lib/components/ui/accordion/index.ts
Normal file
16
apps/frontend/src/lib/components/ui/accordion/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Root from "./accordion.svelte";
|
||||||
|
import Content from "./accordion-content.svelte";
|
||||||
|
import Item from "./accordion-item.svelte";
|
||||||
|
import Trigger from "./accordion-trigger.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Item,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Accordion,
|
||||||
|
Content as AccordionContent,
|
||||||
|
Item as AccordionItem,
|
||||||
|
Trigger as AccordionTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.ActionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
class={cn(buttonVariants(), className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.CancelProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
class={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import AlertDialogPortal from "./alert-dialog-portal.svelte";
|
||||||
|
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||||
|
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPortal {...portalProps}>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Portal {...restProps} />
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AlertDialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
class={cn("text-lg font-semibold", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AlertDialogPrimitive.Root bind:open {...restProps} />
|
||||||
37
apps/frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
37
apps/frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-description"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
apps/frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
apps/frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-title"
|
||||||
|
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
44
apps/frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
apps/frontend/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const alertVariants = tv({
|
||||||
|
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
variant?: AlertVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert"
|
||||||
|
class={cn(alertVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
14
apps/frontend/src/lib/components/ui/alert/index.ts
Normal file
14
apps/frontend/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Root from "./alert.svelte";
|
||||||
|
import Description from "./alert-description.svelte";
|
||||||
|
import Title from "./alert-title.svelte";
|
||||||
|
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Description,
|
||||||
|
Title,
|
||||||
|
//
|
||||||
|
Root as Alert,
|
||||||
|
Description as AlertDescription,
|
||||||
|
Title as AlertTitle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AspectRatio as AspectRatioPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: AspectRatioPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AspectRatioPrimitive.Root bind:ref data-slot="aspect-ratio" {...restProps} />
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import Root from "./aspect-ratio.svelte";
|
||||||
|
|
||||||
|
export { Root, Root as AspectRatio };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.FallbackProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.ImageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-image"
|
||||||
|
class={cn("aspect-square size-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
apps/frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
apps/frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
loadingStatus = $bindable("loading"),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:loadingStatus
|
||||||
|
data-slot="avatar"
|
||||||
|
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
13
apps/frontend/src/lib/components/ui/avatar/index.ts
Normal file
13
apps/frontend/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./avatar.svelte";
|
||||||
|
import Image from "./avatar-image.svelte";
|
||||||
|
import Fallback from "./avatar-fallback.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Image,
|
||||||
|
Fallback,
|
||||||
|
//
|
||||||
|
Root as Avatar,
|
||||||
|
Image as AvatarImage,
|
||||||
|
Fallback as AvatarFallback,
|
||||||
|
};
|
||||||
50
apps/frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
apps/frontend/src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const badgeVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||||
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
href,
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={href ? "a" : "span"}
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="badge"
|
||||||
|
{href}
|
||||||
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</svelte:element>
|
||||||
2
apps/frontend/src/lib/components/ui/badge/index.ts
Normal file
2
apps/frontend/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<EllipsisIcon class="size-4" />
|
||||||
|
<span class="sr-only">More</span>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
class={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
href = undefined,
|
||||||
|
child,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const attrs = $derived({
|
||||||
|
"data-slot": "breadcrumb-link",
|
||||||
|
class: cn("hover:text-foreground transition-colors", className),
|
||||||
|
href,
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: attrs })}
|
||||||
|
{:else}
|
||||||
|
<a bind:this={ref} {...attrs}>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLOlAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ol
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ol>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
class={cn("text-foreground font-normal", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children?.()}
|
||||||
|
{:else}
|
||||||
|
<ChevronRightIcon />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
class={className}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
25
apps/frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
25
apps/frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./breadcrumb.svelte";
|
||||||
|
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||||
|
import Item from "./breadcrumb-item.svelte";
|
||||||
|
import Separator from "./breadcrumb-separator.svelte";
|
||||||
|
import Link from "./breadcrumb-link.svelte";
|
||||||
|
import List from "./breadcrumb-list.svelte";
|
||||||
|
import Page from "./breadcrumb-page.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Ellipsis,
|
||||||
|
Item,
|
||||||
|
Separator,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
Page,
|
||||||
|
//
|
||||||
|
Root as Breadcrumb,
|
||||||
|
Ellipsis as BreadcrumbEllipsis,
|
||||||
|
Item as BreadcrumbItem,
|
||||||
|
Separator as BreadcrumbSeparator,
|
||||||
|
Link as BreadcrumbLink,
|
||||||
|
List as BreadcrumbList,
|
||||||
|
Page as BreadcrumbPage,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Separator> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
{orientation}
|
||||||
|
class={cn("bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
...restProps,
|
||||||
|
class: cn(
|
||||||
|
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<div bind:this={ref} {...mergedProps}>
|
||||||
|
{@render mergedProps.children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonGroupVariants = tv({
|
||||||
|
base: "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"[&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-e-none",
|
||||||
|
vertical:
|
||||||
|
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
orientation?: ButtonGroupOrientation;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
class={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
13
apps/frontend/src/lib/components/ui/button-group/index.ts
Normal file
13
apps/frontend/src/lib/components/ui/button-group/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./button-group.svelte";
|
||||||
|
import Text from "./button-group-text.svelte";
|
||||||
|
import Separator from "./button-group-separator.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Text,
|
||||||
|
Separator,
|
||||||
|
//
|
||||||
|
Root as ButtonGroup,
|
||||||
|
Text as ButtonGroupText,
|
||||||
|
Separator as ButtonGroupSeparator,
|
||||||
|
};
|
||||||
82
apps/frontend/src/lib/components/ui/button/button.svelte
Normal file
82
apps/frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
|
||||||
|
outline:
|
||||||
|
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = "button",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? "link" : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
17
apps/frontend/src/lib/components/ui/button/index.ts
Normal file
17
apps/frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import type Calendar from "./calendar.svelte";
|
||||||
|
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||||
|
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||||
|
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||||
|
|
||||||
|
let {
|
||||||
|
captionLayout,
|
||||||
|
months,
|
||||||
|
monthFormat,
|
||||||
|
years,
|
||||||
|
yearFormat,
|
||||||
|
month,
|
||||||
|
locale,
|
||||||
|
placeholder = $bindable(),
|
||||||
|
monthIndex = 0,
|
||||||
|
}: {
|
||||||
|
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||||
|
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||||
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||||
|
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||||
|
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||||
|
month: DateValue;
|
||||||
|
placeholder: DateValue | undefined;
|
||||||
|
locale: string;
|
||||||
|
monthIndex: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function formatYear(date: DateValue) {
|
||||||
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
|
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||||
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(date: DateValue) {
|
||||||
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
|
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||||
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet MonthSelect()}
|
||||||
|
<CalendarMonthSelect
|
||||||
|
{months}
|
||||||
|
{monthFormat}
|
||||||
|
value={month.month}
|
||||||
|
onchange={(e) => {
|
||||||
|
if (!placeholder) return;
|
||||||
|
const v = Number.parseInt(e.currentTarget.value);
|
||||||
|
const newPlaceholder = placeholder.set({ month: v });
|
||||||
|
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet YearSelect()}
|
||||||
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if captionLayout === "dropdown"}
|
||||||
|
{@render MonthSelect()}
|
||||||
|
{@render YearSelect()}
|
||||||
|
{:else if captionLayout === "dropdown-months"}
|
||||||
|
{@render MonthSelect()}
|
||||||
|
{#if placeholder}
|
||||||
|
{formatYear(placeholder)}
|
||||||
|
{/if}
|
||||||
|
{:else if captionLayout === "dropdown-years"}
|
||||||
|
{#if placeholder}
|
||||||
|
{formatMonth(placeholder)}
|
||||||
|
{/if}
|
||||||
|
{@render YearSelect()}
|
||||||
|
{:else}
|
||||||
|
{formatMonth(month)} {formatYear(month)}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.CellProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Cell
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.DayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Day
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||||
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
|
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||||
|
// Outside months
|
||||||
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
|
// Disabled
|
||||||
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
// Unavailable
|
||||||
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
|
// hover
|
||||||
|
"dark:hover:text-accent-foreground",
|
||||||
|
// focus
|
||||||
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
|
// inner spans
|
||||||
|
"[&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridBodyProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridHeadProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Grid
|
||||||
|
bind:ref
|
||||||
|
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeadCellProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.HeadCell
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeaderProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Header
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeadingProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Heading
|
||||||
|
bind:ref
|
||||||
|
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
onchange,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||||
|
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||||
|
<select {...props} {value} {onchange}>
|
||||||
|
{#each monthItems as monthItem (monthItem.value)}
|
||||||
|
<option
|
||||||
|
value={monthItem.value}
|
||||||
|
selected={value !== undefined
|
||||||
|
? monthItem.value === value
|
||||||
|
: monthItem.value === selectedMonthItem.value}
|
||||||
|
>
|
||||||
|
{monthItem.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.MonthSelect>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
{...restProps}
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "ghost",
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronRightIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPrimitive.NextButton
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant }),
|
||||||
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "ghost",
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronLeftIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPrimitive.PrevButton
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant }),
|
||||||
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||||
|
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||||
|
<select {...props} {value}>
|
||||||
|
{#each yearItems as yearItem (yearItem.value)}
|
||||||
|
<option
|
||||||
|
value={yearItem.value}
|
||||||
|
selected={value !== undefined
|
||||||
|
? yearItem.value === value
|
||||||
|
: yearItem.value === selectedYearItem.value}
|
||||||
|
>
|
||||||
|
{yearItem.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.YearSelect>
|
||||||
|
</span>
|
||||||
115
apps/frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
apps/frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import * as Calendar from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ButtonVariant } from "../button/button.svelte";
|
||||||
|
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
placeholder = $bindable(),
|
||||||
|
class: className,
|
||||||
|
weekdayFormat = "short",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
captionLayout = "label",
|
||||||
|
locale = "en-US",
|
||||||
|
months: monthsProp,
|
||||||
|
years,
|
||||||
|
monthFormat: monthFormatProp,
|
||||||
|
yearFormat = "numeric",
|
||||||
|
day,
|
||||||
|
disableDaysOutsideMonth = false,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||||
|
buttonVariant?: ButtonVariant;
|
||||||
|
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||||
|
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||||
|
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||||
|
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||||
|
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||||
|
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const monthFormat = $derived.by(() => {
|
||||||
|
if (monthFormatProp) return monthFormatProp;
|
||||||
|
if (captionLayout.startsWith("dropdown")) return "short";
|
||||||
|
return "long";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Discriminated Unions + Destructing (required for bindable) do not
|
||||||
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
|
-->
|
||||||
|
<CalendarPrimitive.Root
|
||||||
|
bind:value={value as never}
|
||||||
|
bind:ref
|
||||||
|
bind:placeholder
|
||||||
|
{weekdayFormat}
|
||||||
|
{disableDaysOutsideMonth}
|
||||||
|
class={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{locale}
|
||||||
|
{monthFormat}
|
||||||
|
{yearFormat}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ months, weekdays })}
|
||||||
|
<Calendar.Months>
|
||||||
|
<Calendar.Nav>
|
||||||
|
<Calendar.PrevButton variant={buttonVariant} />
|
||||||
|
<Calendar.NextButton variant={buttonVariant} />
|
||||||
|
</Calendar.Nav>
|
||||||
|
{#each months as month, monthIndex (month)}
|
||||||
|
<Calendar.Month>
|
||||||
|
<Calendar.Header>
|
||||||
|
<Calendar.Caption
|
||||||
|
{captionLayout}
|
||||||
|
months={monthsProp}
|
||||||
|
{monthFormat}
|
||||||
|
{years}
|
||||||
|
{yearFormat}
|
||||||
|
month={month.value}
|
||||||
|
bind:placeholder
|
||||||
|
{locale}
|
||||||
|
{monthIndex}
|
||||||
|
/>
|
||||||
|
</Calendar.Header>
|
||||||
|
<Calendar.Grid>
|
||||||
|
<Calendar.GridHead>
|
||||||
|
<Calendar.GridRow class="select-none">
|
||||||
|
{#each weekdays as weekday (weekday)}
|
||||||
|
<Calendar.HeadCell>
|
||||||
|
{weekday.slice(0, 2)}
|
||||||
|
</Calendar.HeadCell>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridRow>
|
||||||
|
</Calendar.GridHead>
|
||||||
|
<Calendar.GridBody>
|
||||||
|
{#each month.weeks as weekDates (weekDates)}
|
||||||
|
<Calendar.GridRow class="mt-2 w-full">
|
||||||
|
{#each weekDates as date (date)}
|
||||||
|
<Calendar.Cell {date} month={month.value}>
|
||||||
|
{#if day}
|
||||||
|
{@render day({
|
||||||
|
day: date,
|
||||||
|
outsideMonth: !isEqualMonth(date, month.value),
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
<Calendar.Day />
|
||||||
|
{/if}
|
||||||
|
</Calendar.Cell>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridRow>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridBody>
|
||||||
|
</Calendar.Grid>
|
||||||
|
</Calendar.Month>
|
||||||
|
{/each}
|
||||||
|
</Calendar.Months>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.Root>
|
||||||
40
apps/frontend/src/lib/components/ui/calendar/index.ts
Normal file
40
apps/frontend/src/lib/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Root from "./calendar.svelte";
|
||||||
|
import Cell from "./calendar-cell.svelte";
|
||||||
|
import Day from "./calendar-day.svelte";
|
||||||
|
import Grid from "./calendar-grid.svelte";
|
||||||
|
import Header from "./calendar-header.svelte";
|
||||||
|
import Months from "./calendar-months.svelte";
|
||||||
|
import GridRow from "./calendar-grid-row.svelte";
|
||||||
|
import Heading from "./calendar-heading.svelte";
|
||||||
|
import GridBody from "./calendar-grid-body.svelte";
|
||||||
|
import GridHead from "./calendar-grid-head.svelte";
|
||||||
|
import HeadCell from "./calendar-head-cell.svelte";
|
||||||
|
import NextButton from "./calendar-next-button.svelte";
|
||||||
|
import PrevButton from "./calendar-prev-button.svelte";
|
||||||
|
import MonthSelect from "./calendar-month-select.svelte";
|
||||||
|
import YearSelect from "./calendar-year-select.svelte";
|
||||||
|
import Month from "./calendar-month.svelte";
|
||||||
|
import Nav from "./calendar-nav.svelte";
|
||||||
|
import Caption from "./calendar-caption.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Day,
|
||||||
|
Cell,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Months,
|
||||||
|
GridRow,
|
||||||
|
Heading,
|
||||||
|
GridBody,
|
||||||
|
GridHead,
|
||||||
|
HeadCell,
|
||||||
|
NextButton,
|
||||||
|
PrevButton,
|
||||||
|
Nav,
|
||||||
|
Month,
|
||||||
|
YearSelect,
|
||||||
|
MonthSelect,
|
||||||
|
Caption,
|
||||||
|
//
|
||||||
|
Root as Calendar,
|
||||||
|
};
|
||||||
20
apps/frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
apps/frontend/src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
15
apps/frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
apps/frontend/src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
20
apps/frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
apps/frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
apps/frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
apps/frontend/src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-header"
|
||||||
|
class={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
apps/frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
apps/frontend/src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-title"
|
||||||
|
class={cn("leading-none font-semibold", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
apps/frontend/src/lib/components/ui/card/card.svelte
Normal file
23
apps/frontend/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card"
|
||||||
|
class={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
25
apps/frontend/src/lib/components/ui/card/index.ts
Normal file
25
apps/frontend/src/lib/components/ui/card/index.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import emblaCarouselSvelte from "embla-carousel-svelte";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { getEmblaContext } from "./context.js";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
|
||||||
|
const emblaCtx = getEmblaContext("<Carousel.Content/>");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-slot="carousel-content"
|
||||||
|
class="overflow-hidden"
|
||||||
|
use:emblaCarouselSvelte={{
|
||||||
|
options: {
|
||||||
|
container: "[data-embla-container]",
|
||||||
|
slides: "[data-embla-slide]",
|
||||||
|
...emblaCtx.options,
|
||||||
|
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins: emblaCtx.plugins,
|
||||||
|
}}
|
||||||
|
onemblaInit={emblaCtx.onInit}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn(
|
||||||
|
"flex",
|
||||||
|
emblaCtx.orientation === "horizontal" ? "-ms-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-embla-container=""
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { getEmblaContext } from "./context.js";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
|
||||||
|
const emblaCtx = getEmblaContext("<Carousel.Item/>");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="carousel-item"
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
class={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
emblaCtx.orientation === "horizontal" ? "ps-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-embla-slide=""
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
|
||||||
|
import type { WithoutChildren } from "bits-ui";
|
||||||
|
import { getEmblaContext } from "./context.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<Props> = $props();
|
||||||
|
|
||||||
|
const emblaCtx = getEmblaContext("<Carousel.Next/>");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
{variant}
|
||||||
|
{size}
|
||||||
|
aria-disabled={!emblaCtx.canScrollNext}
|
||||||
|
class={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
emblaCtx.orientation === "horizontal"
|
||||||
|
? "-end-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "start-1/2 -bottom-12 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onclick={emblaCtx.scrollNext}
|
||||||
|
onkeydown={emblaCtx.handleKeyDown}
|
||||||
|
bind:ref
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon class="size-4" />
|
||||||
|
<span class="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||||
|
import type { WithoutChildren } from "bits-ui";
|
||||||
|
import { getEmblaContext } from "./context.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<Props> = $props();
|
||||||
|
|
||||||
|
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
{variant}
|
||||||
|
{size}
|
||||||
|
aria-disabled={!emblaCtx.canScrollPrev}
|
||||||
|
class={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
emblaCtx.orientation === "horizontal"
|
||||||
|
? "-start-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "start-1/2 -top-12 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onclick={emblaCtx.scrollPrev}
|
||||||
|
onkeydown={emblaCtx.handleKeyDown}
|
||||||
|
{...restProps}
|
||||||
|
bind:ref
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon class="size-4" />
|
||||||
|
<span class="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user