Compare commits

..

21 Commits

Author SHA1 Message Date
user
49ff819cb5 fixed app stuff in frontend 2026-03-28 20:35:26 +02:00
user
7c210ffe8f fixed app launcher commmand 2026-03-28 20:17:44 +02:00
user
3e018d60f9 poller and release on end to frontend 2026-03-28 20:10:44 +02:00
user
e525c657ac doing a test of auto deploymentssssssss 2026-03-28 20:00:37 +02:00
user
3f469b55cf orch dockerfile update 2026-03-28 19:54:05 +02:00
user
cebcf8a8cb removed placeholders 2026-03-28 19:46:37 +02:00
user
eb85d528d9 updated labels 2026-03-28 19:45:19 +02:00
user
2cd25be2d6 -____- 2026-03-28 19:36:49 +02:00
user
b5da16b4f0 -___- 2026-03-28 19:32:58 +02:00
user
c52612825c -__- 2026-03-28 19:29:08 +02:00
user
ce13ad8124 added default fallback for prod 2026-03-28 19:27:13 +02:00
user
92deee1b2e Implement frontend session routing flow
- Validate and prepare access links in apps/frontend
- Add session, ended, and unauthorized routes with polling
- Copy full public access URLs from the admin links page
2026-03-28 19:10:24 +02:00
user
31a501f75b public url setup 2026-03-28 18:43:39 +02:00
user
2cf28416ec public url setup 2026-03-28 18:43:18 +02:00
user
eee31e5b99 Replace front proxy with new SvelteKit frontend app
- Remove the old Hono/Bun proxy server
- Add the new `apps/frontend` SvelteKit scaffold and telemetry hook
2026-03-28 18:12:43 +02:00
user
0a11be5006 Add link session preparation flow
- wire front link resolve/prepare routes
- add orchestrator session command handling
- update admin dashboards and device/link logic
2026-03-28 17:47:03 +02:00
user
5da61ed853 woops removed useless ws-scrcpy clone 2026-03-28 16:19:41 +02:00
user
671a712b08 supported apps domain + some refactor of data types redundancy 2026-03-28 16:19:24 +02:00
user
6639bcd799 major updates to device and links management in admin 2026-03-28 15:34:03 +02:00
user
e8c5986df6 add memory log with session history and enforce logging rule in CLAUDE.md
Tracks all meaningful changes across sessions (foundation, domain logic,
admin CRUD, ws-scrcpy deployment). Adds agent rule #5 requiring memory
log updates after each meaningful activity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:05:54 +02:00
user
72cdfaab80 added node-gyp install 2026-03-28 13:51:20 +02:00
494 changed files with 17995 additions and 1346 deletions

View File

@@ -8,15 +8,9 @@ DATABASE_URL=${{project.DATABASE_URL}}
INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
DEBUG_KEY=${{project.DEBUG_KEY}}
PUBLIC_URL=${{project.PUBLIC_URL}}
PROCESSOR_API_URL=${{project.PROCESSOR_API_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}}
ORCHESTRATOR_API_URL=${{project.ORCHESTRATOR_API_URL}}
PUBLIC_WS_SCRCPY_SVC_URL=${{project.PUBLIC_WS_SCRCPY_SVC_URL}}
PUBLIC_FRONTEND_URL=${{project.PUBLIC_FRONTEND_URL}}
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
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_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
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}}

View File

@@ -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.
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.
---
@@ -21,7 +23,7 @@ More rules are only to be added by the human, in case such a suggestion becomes
- **Monorepo**: Turborepo + pnpm
- **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`
- **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.
**Path aliases** (logic package only):
- `@/*``./*` · `@domains/*``./domains/*` · `@core/*``./core/*`
**FlowExecCtx** (`fctx`) — passed into every domain operation for tracing:
```ts
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string; };
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string };
```
---

View File

@@ -8,12 +8,13 @@ Currently in alpha. Greenfield. Subject to change.
## How It Works
1. Admin generates a unique link and assigns it to a user (or a slot).
2. User opens that link in their browser — served by `apps/front`.
3. User is shown a loading screen for good UX purposes
4. User is prompted to install the PWA.
5. User opens the PWA — they are routed into a live stream of their assigned Android instance.
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.
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/frontend`.
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. If that device is already in use by another end user, the link fails instead of taking over the session.
5. User is prompted to install the PWA.
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 schema — DB model for a device (host VPS, container ID, status, assigned session, etc.)
- [ ] Device domain in `@pkg/logic` — controller + repository + errors
- [x] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.)
- [x] Device domain in `@pkg/logic` — controller + repository + errors
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
- [ ] `POST /devices/:id/start` — start a Docker-Android container
- [ ] `POST /devices/:id/stop` — stop a container
- [ ] `POST /devices/:id/restart` — restart a container
- [ ] `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)
- [ ] Device allocation logic — mark a device as in-use for a user session
- [ ] Device release logic — free up a device when a session ends
- [ ] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
- [ ] Device allocation logic — atomically mark a device as `inUse` when a validated link starts a session
- [ ] Device release logic — clear `inUse` when a session ends or fails during setup
- [x] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
### Link Management (Admin + Front App)
- [ ] Link schema — DB model (unique token, expiry, status, linked device ID)
- [ ] Link domain in `@pkg/logic` — controller + repository + errors
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device, revoke, delete
- [ ] `apps/front`: validate incoming link token on request
- [ ] `apps/front`: return appropriate error page for invalid/expired/revoked links
- [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
- [x] Link domain in `@pkg/logic` — controller + repository + errors
- [x] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete
- [ ] `apps/frontend`: validate incoming link token on request
- [ ] `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
### PWA & User Session Flow (`apps/front`)
### PWA & User Session Flow (`apps/frontend`)
- [ ] `apps/front`: serve static PWA shell (HTML + manifest + service worker)
- [ ] `apps/front`: wait/loading page — just for show with a 3-5s duration
- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling)
- [ ] `apps/front`: 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`: serve static PWA shell (HTML + manifest + service worker)
- [ ] `apps/frontend`: wait/loading page — just for show with a 3-5s duration
- [ ] `apps/frontend`: PWA install prompt flow (beforeinstallprompt handling)
- [ ] `apps/frontend`: session binding — tie the PWA launch to the user's allocated device
- [ ] `apps/frontend`: route/proxy authenticated PWA requests to the Android instance stream
### Android Streaming (scrcpy + ws-scrcpy)
- [ ] Docker-Android image setup and validation on VPS
- [ ] ws-scrcpy WebSocket server running per container, exposed via orchestrator
- [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser
- [x] Docker-Android image setup and validation on VPS
- [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator
- [ ] `apps/frontend`: scrcpy client embedded in PWA — renders the Android stream in browser
- [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
- [ ] Session timeout + stream teardown on inactivity

View File

@@ -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`,
);

View File

@@ -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"
}
}

View File

@@ -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}`);
},
);

View File

@@ -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"]
}

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

View 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"
}

View 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
View 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 {};

View 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>

View 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);
});
});

View 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;

View File

@@ -1,8 +1,6 @@
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 { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { createAddHookMessageChannel } from "import-in-the-middle";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { NodeSDK } from "@opentelemetry/sdk-node";
@@ -12,23 +10,9 @@ import { register } from "node:module";
const { registerOptions } = createAddHookMessageChannel();
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({
serviceName: `${settings.otelServiceName}-processor`,
serviceName: settings.otelServiceName || settings.appName,
traceExporter: new OTLPTraceExporter(),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter(),
exportIntervalMillis: 10_000,
}),
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
instrumentations: [
getNodeAutoInstrumentations({
@@ -41,10 +25,3 @@ const sdk = new NodeSDK({
});
sdk.start();
const shutdown = () => {
void sdk.shutdown();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

View 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>

View 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}

View File

@@ -0,0 +1,11 @@
<script lang="ts">
let {
icon: Icon,
cls,
}: {
icon: any;
cls?: string;
} = $props();
</script>
<Icon class={cls} />

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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}
/>

View 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,
};

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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}
/>

View 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="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View 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-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />

View File

@@ -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}
/>

View File

@@ -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} />

View File

@@ -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} />

View 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,
};

View 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="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>

View 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>

View 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>

View 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,
};

View File

@@ -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} />

View File

@@ -0,0 +1,3 @@
import Root from "./aspect-ratio.svelte";
export { Root, Root as AspectRatio };

View File

@@ -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}
/>

View File

@@ -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}
/>

View 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}
/>

View 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,
};

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View 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<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>

View File

@@ -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>

View File

@@ -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>

View 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,
};

View File

@@ -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}
/>

View File

@@ -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}

View File

@@ -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>

View 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,
};

View 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}

View 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,
};

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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>

View 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>

View 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,
};

View 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>

View 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>

View 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<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View 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>

View 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>

View 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>

View 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>

View 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,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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