initttt
This commit is contained in:
33
apps/front/package.json
Normal file
33
apps/front/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
153
apps/front/src/index.ts
Normal file
153
apps/front/src/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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}`);
|
||||
},
|
||||
);
|
||||
50
apps/front/src/instrumentation.ts
Normal file
50
apps/front/src/instrumentation.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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 { createAddHookMessageChannel } from "import-in-the-middle";
|
||||
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { settings } from "@pkg/settings";
|
||||
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`,
|
||||
traceExporter: new OTLPTraceExporter(),
|
||||
metricReader: new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter(),
|
||||
exportIntervalMillis: 10_000,
|
||||
}),
|
||||
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
"@opentelemetry/instrumentation-winston": {
|
||||
// We add OpenTelemetryTransportV3 explicitly in @pkg/logger.
|
||||
disableLogSending: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
|
||||
const shutdown = () => {
|
||||
void sdk.shutdown();
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
33
apps/front/tsconfig.json
Normal file
33
apps/front/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
290
apps/front/view.html
Normal file
290
apps/front/view.html
Normal file
@@ -0,0 +1,290 @@
|
||||
<!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("#maitm-hide-css")) return;
|
||||
const style = doc.createElement("style");
|
||||
style.id = "maitm-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/main/.gitignore
vendored
Normal file
23
apps/main/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
apps/main/.npmrc
Normal file
1
apps/main/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
9
apps/main/.prettierignore
Normal file
9
apps/main/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
16
apps/main/components.json
Normal file
16
apps/main/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/routes/layout.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
78
apps/main/package.json
Normal file
78
apps/main/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@apps/main",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5173",
|
||||
"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:*",
|
||||
"argon2": "^0.43.0",
|
||||
"better-auth": "^1.4.20",
|
||||
"date-fns": "^4.1.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"neverthrow": "^8.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5",
|
||||
"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/main/src/app.d.ts
vendored
Normal file
20
apps/main/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Session, User } from "@pkg/logic/domains/user/data";
|
||||
import "unplugin-icons/types/svelte";
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
flowId?: string;
|
||||
session?: Session;
|
||||
user?: User;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
apps/main/src/app.html
Normal file
11
apps/main/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
7
apps/main/src/demo.spec.ts
Normal file
7
apps/main/src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
79
apps/main/src/hooks.server.ts
Normal file
79
apps/main/src/hooks.server.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { checkInitial2FaRequired } from "@pkg/logic/domains/2fa/sensitive-actions";
|
||||
import type { Handle, HandleServerError } from "@sveltejs/kit";
|
||||
import { auth } from "@pkg/logic/domains/auth/config.base";
|
||||
import { svelteKitHandler } from "better-auth/svelte-kit";
|
||||
import type { User } from "@pkg/logic/domains/user/data";
|
||||
import { sequence } from "@sveltejs/kit/hooks";
|
||||
import { building } from "$app/environment";
|
||||
|
||||
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 zero: Handle = async ({ event, resolve }) => {
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
};
|
||||
|
||||
export const first: Handle = async ({ event, resolve }) => {
|
||||
if (
|
||||
event.url.pathname.includes("/api/v1") ||
|
||||
event.url.pathname.includes("/api/auth") ||
|
||||
event.url.pathname.includes("/api/debug") ||
|
||||
event.url.pathname.includes("/api/chat") ||
|
||||
event.url.pathname.includes("/auth") ||
|
||||
event.url.pathname.includes("/link")
|
||||
) {
|
||||
return await resolve(event);
|
||||
}
|
||||
console.log("[+] Running middleware for : ", event.url.pathname);
|
||||
const baseUrl = event.url.origin;
|
||||
const signInUrl = baseUrl + "/auth/login";
|
||||
const isSignInPage = event.url.pathname === "/auth/login";
|
||||
const redirectResponse = new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: signInUrl },
|
||||
});
|
||||
|
||||
const u = await auth.api.getSession({ headers: event.request.headers });
|
||||
if (!u || !u.user || !u.session) {
|
||||
return redirectResponse;
|
||||
}
|
||||
if (u.user && isSignInPage) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: baseUrl },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Setting user & session to locals");
|
||||
|
||||
const fid = crypto.randomUUID();
|
||||
|
||||
event.locals.flowId = fid;
|
||||
event.locals.session = u.session;
|
||||
event.locals.user = u.user as any as User;
|
||||
|
||||
const needs2FA = await checkInitial2FaRequired(
|
||||
{
|
||||
flowId: fid,
|
||||
userId: u.user.id,
|
||||
sessionId: u.session.id,
|
||||
},
|
||||
u.user as any,
|
||||
u.session.id,
|
||||
);
|
||||
if (needs2FA && !event.url.pathname.includes("/auth/2fa")) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/auth/2fa?redirect=${encodeURIComponent(event.url.pathname)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle = sequence(zero, first);
|
||||
27
apps/main/src/instrumentation.server.ts
Normal file
27
apps/main/src/instrumentation.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||
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";
|
||||
import { settings } from "@pkg/settings";
|
||||
import { register } from "node:module";
|
||||
|
||||
const { registerOptions } = createAddHookMessageChannel();
|
||||
register("import-in-the-middle/hook.mjs", import.meta.url, registerOptions);
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
serviceName: settings.otelServiceName || settings.appName,
|
||||
traceExporter: new OTLPTraceExporter(),
|
||||
logRecordProcessors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
"@opentelemetry/instrumentation-winston": {
|
||||
// We add OpenTelemetryTransportV3 explicitly in @pkg/logger.
|
||||
disableLogSending: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
19
apps/main/src/lib/auth.client.ts
Normal file
19
apps/main/src/lib/auth.client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
adminClient,
|
||||
customSessionClient,
|
||||
inferAdditionalFields,
|
||||
multiSessionClient,
|
||||
usernameClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import type { auth } from "@pkg/logic/domains/auth/config.base";
|
||||
import { createAuthClient } from "better-auth/svelte";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
usernameClient(),
|
||||
adminClient(),
|
||||
multiSessionClient(),
|
||||
customSessionClient<typeof auth>(),
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
],
|
||||
});
|
||||
35
apps/main/src/lib/components/app-sidebar.svelte
Normal file
35
apps/main/src/lib/components/app-sidebar.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" module>
|
||||
import BriefcaseIcon from "@lucide/svelte/icons/briefcase";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import NavMain from "$lib/components/nav-main.svelte";
|
||||
import NavUser from "$lib/components/nav-user.svelte";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
const data = $state({
|
||||
teams: [{ name: "Personal", logo: BriefcaseIcon, plan: "Standard" }],
|
||||
navItems: mainNavTree,
|
||||
});
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
collapsible = "icon",
|
||||
...restProps
|
||||
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Root bind:ref {collapsible} {...restProps}>
|
||||
<!-- <Sidebar.Header>
|
||||
<TeamSwitcher teams={data.teams} />
|
||||
</Sidebar.Header> -->
|
||||
<Sidebar.Content class="pt-2">
|
||||
<NavMain items={data.navItems} />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<NavUser />
|
||||
</Sidebar.Footer>
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
||||
17
apps/main/src/lib/components/atoms/button-text.svelte
Normal file
17
apps/main/src/lib/components/atoms/button-text.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import LoaderCircleIcon from "~icons/lucide/loader-circle";
|
||||
import Icon from "./icon.svelte";
|
||||
|
||||
let {
|
||||
loading,
|
||||
loadingText,
|
||||
text,
|
||||
}: { loading: boolean; loadingText: string; text: string } = $props();
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<Icon icon={LoaderCircleIcon} cls="h-5 w-auto animate-spin" />
|
||||
{loadingText}
|
||||
{:else}
|
||||
{text}
|
||||
{/if}
|
||||
11
apps/main/src/lib/components/atoms/icon.svelte
Normal file
11
apps/main/src/lib/components/atoms/icon.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
icon: Icon,
|
||||
cls,
|
||||
}: {
|
||||
icon: any;
|
||||
cls?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Icon class={cls} />
|
||||
61
apps/main/src/lib/components/atoms/title.svelte
Normal file
61
apps/main/src/lib/components/atoms/title.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
type TitleSize = "h1" | "h2" | "h3" | "h4" | "h5" | "p";
|
||||
type TitleFontWeight = "bold" | "semibold" | "normal" | "medium";
|
||||
|
||||
const colors = {
|
||||
theme: "text-primary",
|
||||
white: "text-white",
|
||||
black: "text-black",
|
||||
primaryLight: "text-primary/80",
|
||||
};
|
||||
|
||||
const weights = {
|
||||
bold: "font-bold",
|
||||
semibold: "font-semibold",
|
||||
medium: "font-medium",
|
||||
normal: "font-normal",
|
||||
} as const;
|
||||
|
||||
const sizes = {
|
||||
h1: "text-6xl md:text-7xl",
|
||||
h2: "text-5xl lg:text-6xl",
|
||||
h3: "text-4xl lg:text-5xl",
|
||||
h4: "text-2xl lg:text-3xl",
|
||||
h5: "text-xl lg:text-2xl",
|
||||
p: "text-lg lg:text-xl",
|
||||
};
|
||||
|
||||
let {
|
||||
size = "h2",
|
||||
weight = "semibold",
|
||||
capitalize = true,
|
||||
color = "theme",
|
||||
center = false,
|
||||
id = undefined,
|
||||
children,
|
||||
}: {
|
||||
size?: TitleSize;
|
||||
weight?: TitleFontWeight;
|
||||
capitalize?: boolean;
|
||||
color?: keyof typeof colors;
|
||||
center?: boolean;
|
||||
id?: string;
|
||||
children?: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={size}
|
||||
class={cn(
|
||||
sizes[size] || sizes.p,
|
||||
weights[weight ?? "bold"],
|
||||
capitalize && "capitalize",
|
||||
colors[color],
|
||||
center && "text-center",
|
||||
)}
|
||||
{id}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { cls, children }: { cls: string; children: any } = $props();
|
||||
|
||||
let clsss = cn("mx-auto h-full w-full max-w-screen-xl", cls);
|
||||
</script>
|
||||
|
||||
<div class={clsss}>
|
||||
{@render children()}
|
||||
</div>
|
||||
81
apps/main/src/lib/components/nav-main.svelte
Normal file
81
apps/main/src/lib/components/nav-main.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import type { AppSidebarItem } from "$lib/core/constants";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import Icon from "./atoms/icon.svelte";
|
||||
|
||||
let { items }: { items: AppSidebarItem[] } = $props();
|
||||
|
||||
const sidebar = Sidebar.useSidebar();
|
||||
</script>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each items as mainItem (mainItem.title)}
|
||||
<Collapsible.Root
|
||||
open={mainItem.isActive}
|
||||
class="group/collapsible"
|
||||
>
|
||||
<Sidebar.MenuItem>
|
||||
<!-- Split the behavior: main button redirects, chevron expands -->
|
||||
<div class="flex w-full items-center">
|
||||
<!-- Main navigation button that redirects -->
|
||||
<Sidebar.MenuButton class="flex-1 pr-1">
|
||||
{#snippet child({ props })}
|
||||
<a href={mainItem.url} {...props}>
|
||||
{#if mainItem.icon}
|
||||
<mainItem.icon />
|
||||
{/if}
|
||||
<span>{mainItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
<!-- Chevron button that only toggles the collapsible content -->
|
||||
{#if mainItem.items && sidebar.open}
|
||||
<Collapsible.Trigger class="p-2">
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
{...props}
|
||||
class="bg-background hover:bg-accent flex cursor-pointer items-center justify-center rounded-sm p-1"
|
||||
>
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
cls="w-4 h-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
<span class="sr-only"
|
||||
>Toggle {mainItem.title} submenu</span
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Collapsible submenu content -->
|
||||
<Collapsible.Content>
|
||||
{#if mainItem.items}
|
||||
<Sidebar.MenuSub>
|
||||
{#each mainItem.items as subItem (subItem.title)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href={subItem.url}
|
||||
{...props}
|
||||
>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
{/if}
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
150
apps/main/src/lib/components/nav-user.svelte
Normal file
150
apps/main/src/lib/components/nav-user.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from "$lib/components/ui/avatar/index.js";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||
import { secondaryNavTree } from "$lib/core/constants";
|
||||
import { sessionsVM } from "$lib/domains/account/sessions/sessions.vm.svelte";
|
||||
import { user as userStore } from "$lib/global.stores";
|
||||
import { type User } from "@pkg/logic/domains/user/data";
|
||||
import { resetMode, setMode } from "mode-watcher";
|
||||
import ChevronsUpDown from "~icons/lucide/chevrons-up-down";
|
||||
import LogOut from "~icons/lucide/log-out";
|
||||
import Monitor from "~icons/lucide/monitor";
|
||||
import Moon from "~icons/lucide/moon";
|
||||
import Sun from "~icons/lucide/sun";
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let user = $state({
|
||||
name: "",
|
||||
email: "",
|
||||
image: "",
|
||||
...($userStore || {}),
|
||||
} as any as User);
|
||||
|
||||
userStore.subscribe((value) => {
|
||||
if (value) {
|
||||
user = value;
|
||||
}
|
||||
if (user.name.length < 2) {
|
||||
// @ts-ignore
|
||||
user.name = "User";
|
||||
}
|
||||
});
|
||||
|
||||
$inspect(user);
|
||||
|
||||
async function logoutUser() {
|
||||
await sessionsVM.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
{...props}
|
||||
>
|
||||
<Avatar.Root class="h-8 w-8 rounded-lg">
|
||||
<Avatar.Image src={user.image} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">
|
||||
{user.name.slice(0, 2).toUpperCase()}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight"
|
||||
>
|
||||
<span class="truncate font-semibold"
|
||||
>{user.name}</span
|
||||
>
|
||||
<span class="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="w-[var(--bits-dropdown-menu-anchor-width)] min-w-56 rounded-lg"
|
||||
side={sidebar.isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="p-0 font-normal">
|
||||
<div
|
||||
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
|
||||
>
|
||||
<Avatar.Root class="h-8 w-8 rounded-lg">
|
||||
<Avatar.Image src={user.image} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">
|
||||
{user.name.slice(0, 2).toUpperCase()}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight"
|
||||
>
|
||||
<span class="truncate font-semibold"
|
||||
>{user.name}</span
|
||||
>
|
||||
<span class="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Group>
|
||||
{#each secondaryNavTree as item (item.title)}
|
||||
<DropdownMenu.Item>
|
||||
<a
|
||||
href={item.url}
|
||||
class="flex h-full w-full items-center gap-2"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<!-- Theme switcher section -->
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger>
|
||||
<Sun
|
||||
class="h-4 w-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
|
||||
/>
|
||||
<Moon
|
||||
class="absolute h-4 w-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
|
||||
/>
|
||||
<span>Theme</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent>
|
||||
<DropdownMenu.Item onclick={() => setMode("light")}>
|
||||
<Sun class="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => setMode("dark")}>
|
||||
<Moon class="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => resetMode()}>
|
||||
<Monitor class="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => logoutUser()}>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
80
apps/main/src/lib/components/team-switcher.svelte
Normal file
80
apps/main/src/lib/components/team-switcher.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||
import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
|
||||
// This should be `Component` after @lucide/svelte updates types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let { teams }: { teams: { name: string; logo: any; plan: string }[] } =
|
||||
$props();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let activeTeam = $state(teams[0]);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<activeTeam.logo class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">
|
||||
{activeTeam.name}
|
||||
</span>
|
||||
<span class="truncate text-xs">{activeTeam.plan}</span
|
||||
>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="w-[var(--bits-dropdown-menu-anchor-width)] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-muted-foreground text-xs"
|
||||
>Teams</DropdownMenu.Label
|
||||
>
|
||||
{#each teams as team, index (team.name)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => (activeTeam = team)}
|
||||
class="gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
>
|
||||
<team.logo class="size-4 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="gap-2 p-2">
|
||||
<div
|
||||
class="bg-background flex size-6 items-center justify-center rounded-md border"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="text-muted-foreground font-medium">
|
||||
Coming soon
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="accordion-content"
|
||||
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...restProps}
|
||||
>
|
||||
<div class={cn("pt-0 pb-4", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="accordion-item"
|
||||
class={cn("border-b last:border-b-0", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||
level?: AccordionPrimitive.HeaderProps["level"];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
bind:ref
|
||||
class={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon
|
||||
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
16
apps/main/src/lib/components/ui/accordion/accordion.svelte
Normal file
16
apps/main/src/lib/components/ui/accordion/accordion.svelte
Normal 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}
|
||||
/>
|
||||
16
apps/main/src/lib/components/ui/accordion/index.ts
Normal file
16
apps/main/src/lib/components/ui/accordion/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Root from "./accordion.svelte";
|
||||
import Content from "./accordion-content.svelte";
|
||||
import Item from "./accordion-item.svelte";
|
||||
import Trigger from "./accordion-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Trigger,
|
||||
//
|
||||
Root as Accordion,
|
||||
Content as AccordionContent,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action
|
||||
bind:ref
|
||||
data-slot="alert-dialog-action"
|
||||
class={cn(buttonVariants(), className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
data-slot="alert-dialog-cancel"
|
||||
class={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import AlertDialogPortal from "./alert-dialog-portal.svelte";
|
||||
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPortal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="alert-dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="alert-dialog-title"
|
||||
class={cn("text-lg font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Root bind:open {...restProps} />
|
||||
37
apps/main/src/lib/components/ui/alert-dialog/index.ts
Normal file
37
apps/main/src/lib/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Root from "./alert-dialog.svelte";
|
||||
import Portal from "./alert-dialog-portal.svelte";
|
||||
import Trigger from "./alert-dialog-trigger.svelte";
|
||||
import Title from "./alert-dialog-title.svelte";
|
||||
import Action from "./alert-dialog-action.svelte";
|
||||
import Cancel from "./alert-dialog-cancel.svelte";
|
||||
import Footer from "./alert-dialog-footer.svelte";
|
||||
import Header from "./alert-dialog-header.svelte";
|
||||
import Overlay from "./alert-dialog-overlay.svelte";
|
||||
import Content from "./alert-dialog-content.svelte";
|
||||
import Description from "./alert-dialog-description.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
apps/main/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
apps/main/src/lib/components/ui/alert/alert-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
44
apps/main/src/lib/components/ui/alert/alert.svelte
Normal file
44
apps/main/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const alertVariants = tv({
|
||||
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
variant?: AlertVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert"
|
||||
class={cn(alertVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
role="alert"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
14
apps/main/src/lib/components/ui/alert/index.ts
Normal file
14
apps/main/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Root from "./alert.svelte";
|
||||
import Description from "./alert-description.svelte";
|
||||
import Title from "./alert-title.svelte";
|
||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AspectRatio as AspectRatioPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AspectRatioPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AspectRatioPrimitive.Root bind:ref data-slot="aspect-ratio" {...restProps} />
|
||||
3
apps/main/src/lib/components/ui/aspect-ratio/index.ts
Normal file
3
apps/main/src/lib/components/ui/aspect-ratio/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Root from "./aspect-ratio.svelte";
|
||||
|
||||
export { Root, Root as AspectRatio };
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
apps/main/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
apps/main/src/lib/components/ui/avatar/avatar-image.svelte
Normal 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}
|
||||
/>
|
||||
19
apps/main/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
apps/main/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
13
apps/main/src/lib/components/ui/avatar/index.ts
Normal file
13
apps/main/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
50
apps/main/src/lib/components/ui/badge/badge.svelte
Normal file
50
apps/main/src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
2
apps/main/src/lib/components/ui/badge/index.ts
Normal file
2
apps/main/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("flex size-9 items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<EllipsisIcon class="size-4" />
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-item"
|
||||
class={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
href = undefined,
|
||||
child,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||
} = $props();
|
||||
|
||||
const attrs = $derived({
|
||||
"data-slot": "breadcrumb-link",
|
||||
class: cn("hover:text-foreground transition-colors", className),
|
||||
href,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: attrs })}
|
||||
{:else}
|
||||
<a bind:this={ref} {...attrs}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLOlAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-list"
|
||||
class={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ol>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
class={cn("text-foreground font-normal", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn("[&>svg]:size-3.5", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<ChevronRightIcon />
|
||||
{/if}
|
||||
</li>
|
||||
21
apps/main/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
21
apps/main/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal 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>
|
||||
25
apps/main/src/lib/components/ui/breadcrumb/index.ts
Normal file
25
apps/main/src/lib/components/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./breadcrumb.svelte";
|
||||
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||
import Item from "./breadcrumb-item.svelte";
|
||||
import Separator from "./breadcrumb-separator.svelte";
|
||||
import Link from "./breadcrumb-link.svelte";
|
||||
import List from "./breadcrumb-list.svelte";
|
||||
import Page from "./breadcrumb-page.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Ellipsis,
|
||||
Item,
|
||||
Separator,
|
||||
Link,
|
||||
List,
|
||||
Page,
|
||||
//
|
||||
Root as Breadcrumb,
|
||||
Ellipsis as BreadcrumbEllipsis,
|
||||
Item as BreadcrumbItem,
|
||||
Separator as BreadcrumbSeparator,
|
||||
Link as BreadcrumbLink,
|
||||
List as BreadcrumbList,
|
||||
Page as BreadcrumbPage,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
orientation = "vertical",
|
||||
...restProps
|
||||
}: ComponentProps<typeof Separator> = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="button-group-separator"
|
||||
{orientation}
|
||||
class={cn("bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
child,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
} = $props();
|
||||
|
||||
const mergedProps = $derived({
|
||||
...restProps,
|
||||
class: cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
),
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div bind:this={ref} {...mergedProps}>
|
||||
{@render mergedProps.children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const buttonGroupVariants = tv({
|
||||
base: "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-e-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
orientation = "horizontal",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
orientation?: ButtonGroupOrientation;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
class={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
13
apps/main/src/lib/components/ui/button-group/index.ts
Normal file
13
apps/main/src/lib/components/ui/button-group/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Root from "./button-group.svelte";
|
||||
import Text from "./button-group-text.svelte";
|
||||
import Separator from "./button-group-separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Text,
|
||||
Separator,
|
||||
//
|
||||
Root as ButtonGroup,
|
||||
Text as ButtonGroupText,
|
||||
Separator as ButtonGroupSeparator,
|
||||
};
|
||||
82
apps/main/src/lib/components/ui/button/button.svelte
Normal file
82
apps/main/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
|
||||
outline:
|
||||
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
apps/main/src/lib/components/ui/button/index.ts
Normal file
17
apps/main/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type Calendar from "./calendar.svelte";
|
||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||
|
||||
let {
|
||||
captionLayout,
|
||||
months,
|
||||
monthFormat,
|
||||
years,
|
||||
yearFormat,
|
||||
month,
|
||||
locale,
|
||||
placeholder = $bindable(),
|
||||
monthIndex = 0,
|
||||
}: {
|
||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||
month: DateValue;
|
||||
placeholder: DateValue | undefined;
|
||||
locale: string;
|
||||
monthIndex: number;
|
||||
} = $props();
|
||||
|
||||
function formatYear(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||
}
|
||||
|
||||
function formatMonth(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet MonthSelect()}
|
||||
<CalendarMonthSelect
|
||||
{months}
|
||||
{monthFormat}
|
||||
value={month.month}
|
||||
onchange={(e) => {
|
||||
if (!placeholder) return;
|
||||
const v = Number.parseInt(e.currentTarget.value);
|
||||
const newPlaceholder = placeholder.set({ month: v });
|
||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet YearSelect()}
|
||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||
{/snippet}
|
||||
|
||||
{#if captionLayout === "dropdown"}
|
||||
{@render MonthSelect()}
|
||||
{@render YearSelect()}
|
||||
{:else if captionLayout === "dropdown-months"}
|
||||
{@render MonthSelect()}
|
||||
{#if placeholder}
|
||||
{formatYear(placeholder)}
|
||||
{/if}
|
||||
{:else if captionLayout === "dropdown-years"}
|
||||
{#if placeholder}
|
||||
{formatMonth(placeholder)}
|
||||
{/if}
|
||||
{@render YearSelect()}
|
||||
{:else}
|
||||
{formatMonth(month)} {formatYear(month)}
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.CellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
35
apps/main/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
35
apps/main/src/lib/components/ui/calendar/calendar-day.svelte
Normal 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}
|
||||
/>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridBodyProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridHeadProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridRowProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid
|
||||
bind:ref
|
||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadCellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
bind:ref
|
||||
class={cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
bind:ref
|
||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
onchange,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||
<select {...props} {value} {onchange}>
|
||||
{#each monthItems as monthItem (monthItem.value)}
|
||||
<option
|
||||
value={monthItem.value}
|
||||
selected={value !== undefined
|
||||
? monthItem.value === value
|
||||
: monthItem.value === selectedMonthItem.value}
|
||||
>
|
||||
{monthItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.MonthSelect>
|
||||
</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
19
apps/main/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
19
apps/main/src/lib/components/ui/calendar/calendar-nav.svelte
Normal 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>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.NextButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.PrevButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||
<select {...props} {value}>
|
||||
{#each yearItems as yearItem (yearItem.value)}
|
||||
<option
|
||||
value={yearItem.value}
|
||||
selected={value !== undefined
|
||||
? yearItem.value === value
|
||||
: yearItem.value === selectedYearItem.value}
|
||||
>
|
||||
{yearItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.YearSelect>
|
||||
</span>
|
||||
115
apps/main/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
apps/main/src/lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import * as Calendar from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ButtonVariant } from "../button/button.svelte";
|
||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
class: className,
|
||||
weekdayFormat = "short",
|
||||
buttonVariant = "ghost",
|
||||
captionLayout = "label",
|
||||
locale = "en-US",
|
||||
months: monthsProp,
|
||||
years,
|
||||
monthFormat: monthFormatProp,
|
||||
yearFormat = "numeric",
|
||||
day,
|
||||
disableDaysOutsideMonth = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||
buttonVariant?: ButtonVariant;
|
||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||
} = $props();
|
||||
|
||||
const monthFormat = $derived.by(() => {
|
||||
if (monthFormatProp) return monthFormatProp;
|
||||
if (captionLayout.startsWith("dropdown")) return "short";
|
||||
return "long";
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<CalendarPrimitive.Root
|
||||
bind:value={value as never}
|
||||
bind:ref
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
{disableDaysOutsideMonth}
|
||||
class={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{locale}
|
||||
{monthFormat}
|
||||
{yearFormat}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<Calendar.Months>
|
||||
<Calendar.Nav>
|
||||
<Calendar.PrevButton variant={buttonVariant} />
|
||||
<Calendar.NextButton variant={buttonVariant} />
|
||||
</Calendar.Nav>
|
||||
{#each months as month, monthIndex (month)}
|
||||
<Calendar.Month>
|
||||
<Calendar.Header>
|
||||
<Calendar.Caption
|
||||
{captionLayout}
|
||||
months={monthsProp}
|
||||
{monthFormat}
|
||||
{years}
|
||||
{yearFormat}
|
||||
month={month.value}
|
||||
bind:placeholder
|
||||
{locale}
|
||||
{monthIndex}
|
||||
/>
|
||||
</Calendar.Header>
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="select-none">
|
||||
{#each weekdays as weekday (weekday)}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates (weekDates)}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date (date)}
|
||||
<Calendar.Cell {date} month={month.value}>
|
||||
{#if day}
|
||||
{@render day({
|
||||
day: date,
|
||||
outsideMonth: !isEqualMonth(date, month.value),
|
||||
})}
|
||||
{:else}
|
||||
<Calendar.Day />
|
||||
{/if}
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</Calendar.Month>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.Root>
|
||||
40
apps/main/src/lib/components/ui/calendar/index.ts
Normal file
40
apps/main/src/lib/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import Root from "./calendar.svelte";
|
||||
import Cell from "./calendar-cell.svelte";
|
||||
import Day from "./calendar-day.svelte";
|
||||
import Grid from "./calendar-grid.svelte";
|
||||
import Header from "./calendar-header.svelte";
|
||||
import Months from "./calendar-months.svelte";
|
||||
import GridRow from "./calendar-grid-row.svelte";
|
||||
import Heading from "./calendar-heading.svelte";
|
||||
import GridBody from "./calendar-grid-body.svelte";
|
||||
import GridHead from "./calendar-grid-head.svelte";
|
||||
import HeadCell from "./calendar-head-cell.svelte";
|
||||
import NextButton from "./calendar-next-button.svelte";
|
||||
import PrevButton from "./calendar-prev-button.svelte";
|
||||
import MonthSelect from "./calendar-month-select.svelte";
|
||||
import YearSelect from "./calendar-year-select.svelte";
|
||||
import Month from "./calendar-month.svelte";
|
||||
import Nav from "./calendar-nav.svelte";
|
||||
import Caption from "./calendar-caption.svelte";
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
Nav,
|
||||
Month,
|
||||
YearSelect,
|
||||
MonthSelect,
|
||||
Caption,
|
||||
//
|
||||
Root as Calendar,
|
||||
};
|
||||
20
apps/main/src/lib/components/ui/card/card-action.svelte
Normal file
20
apps/main/src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
apps/main/src/lib/components/ui/card/card-content.svelte
Normal file
15
apps/main/src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
apps/main/src/lib/components/ui/card/card-description.svelte
Normal file
20
apps/main/src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
20
apps/main/src/lib/components/ui/card/card-footer.svelte
Normal file
20
apps/main/src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
apps/main/src/lib/components/ui/card/card-header.svelte
Normal file
23
apps/main/src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
apps/main/src/lib/components/ui/card/card-title.svelte
Normal file
20
apps/main/src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("leading-none font-semibold", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
apps/main/src/lib/components/ui/card/card.svelte
Normal file
23
apps/main/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
apps/main/src/lib/components/ui/card/index.ts
Normal file
25
apps/main/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import emblaCarouselSvelte from "embla-carousel-svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Content/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-slot="carousel-content"
|
||||
class="overflow-hidden"
|
||||
use:emblaCarouselSvelte={{
|
||||
options: {
|
||||
container: "[data-embla-container]",
|
||||
slides: "[data-embla-slide]",
|
||||
...emblaCtx.options,
|
||||
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins: emblaCtx.plugins,
|
||||
}}
|
||||
onemblaInit={emblaCtx.onInit}
|
||||
>
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"flex",
|
||||
emblaCtx.orientation === "horizontal" ? "-ms-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
data-embla-container=""
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Item/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="carousel-item"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
class={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
emblaCtx.orientation === "horizontal" ? "ps-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
data-embla-slide=""
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
|
||||
import type { WithoutChildren } from "bits-ui";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Next/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
{variant}
|
||||
{size}
|
||||
aria-disabled={!emblaCtx.canScrollNext}
|
||||
class={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-end-12 top-1/2 -translate-y-1/2"
|
||||
: "start-1/2 -bottom-12 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
onclick={emblaCtx.scrollNext}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
>
|
||||
<ArrowRightIcon class="size-4" />
|
||||
<span class="sr-only">Next slide</span>
|
||||
</Button>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||
import type { WithoutChildren } from "bits-ui";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
{variant}
|
||||
{size}
|
||||
aria-disabled={!emblaCtx.canScrollPrev}
|
||||
class={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-start-12 top-1/2 -translate-y-1/2"
|
||||
: "start-1/2 -top-12 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
onclick={emblaCtx.scrollPrev}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
<span class="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
93
apps/main/src/lib/components/ui/carousel/carousel.svelte
Normal file
93
apps/main/src/lib/components/ui/carousel/carousel.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type CarouselAPI,
|
||||
type CarouselProps,
|
||||
type EmblaContext,
|
||||
setEmblaContext,
|
||||
} from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
opts = {},
|
||||
plugins = [],
|
||||
setApi = () => {},
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<CarouselProps> = $props();
|
||||
|
||||
let carouselState = $state<EmblaContext>({
|
||||
api: undefined,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
orientation,
|
||||
canScrollNext: false,
|
||||
canScrollPrev: false,
|
||||
handleKeyDown,
|
||||
options: opts,
|
||||
plugins,
|
||||
onInit,
|
||||
scrollSnaps: [],
|
||||
selectedIndex: 0,
|
||||
scrollTo,
|
||||
});
|
||||
|
||||
setEmblaContext(carouselState);
|
||||
|
||||
function scrollPrev() {
|
||||
carouselState.api?.scrollPrev();
|
||||
}
|
||||
|
||||
function scrollNext() {
|
||||
carouselState.api?.scrollNext();
|
||||
}
|
||||
|
||||
function scrollTo(index: number, jump?: boolean) {
|
||||
carouselState.api?.scrollTo(index, jump);
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!carouselState.api) return;
|
||||
carouselState.selectedIndex = carouselState.api.selectedScrollSnap();
|
||||
carouselState.canScrollNext = carouselState.api.canScrollNext();
|
||||
carouselState.canScrollPrev = carouselState.api.canScrollPrev();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
}
|
||||
|
||||
function onInit(event: CustomEvent<CarouselAPI>) {
|
||||
carouselState.api = event.detail;
|
||||
setApi(carouselState.api);
|
||||
|
||||
carouselState.scrollSnaps = carouselState.api.scrollSnapList();
|
||||
carouselState.api.on("select", onSelect);
|
||||
onSelect();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
carouselState.api?.off("select", onSelect);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="carousel"
|
||||
class={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
58
apps/main/src/lib/components/ui/carousel/context.ts
Normal file
58
apps/main/src/lib/components/ui/carousel/context.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
import type {
|
||||
EmblaCarouselSvelteType,
|
||||
default as emblaCarouselSvelte,
|
||||
} from "embla-carousel-svelte";
|
||||
import { getContext, hasContext, setContext } from "svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
export type CarouselAPI =
|
||||
NonNullable<NonNullable<EmblaCarouselSvelteType["$$_attributes"]>["on:emblaInit"]> extends (
|
||||
evt: CustomEvent<infer CarouselAPI>
|
||||
) => void
|
||||
? CarouselAPI
|
||||
: never;
|
||||
|
||||
type EmblaCarouselConfig = NonNullable<Parameters<typeof emblaCarouselSvelte>[1]>;
|
||||
|
||||
export type CarouselOptions = EmblaCarouselConfig["options"];
|
||||
export type CarouselPlugins = EmblaCarouselConfig["plugins"];
|
||||
|
||||
////
|
||||
|
||||
export type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugins;
|
||||
setApi?: (api: CarouselAPI | undefined) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
} & WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
||||
|
||||
const EMBLA_CAROUSEL_CONTEXT = Symbol("EMBLA_CAROUSEL_CONTEXT");
|
||||
|
||||
export type EmblaContext = {
|
||||
api: CarouselAPI | undefined;
|
||||
orientation: "horizontal" | "vertical";
|
||||
scrollNext: () => void;
|
||||
scrollPrev: () => void;
|
||||
canScrollNext: boolean;
|
||||
canScrollPrev: boolean;
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
options: CarouselOptions;
|
||||
plugins: CarouselPlugins;
|
||||
onInit: (e: CustomEvent<CarouselAPI>) => void;
|
||||
scrollTo: (index: number, jump?: boolean) => void;
|
||||
scrollSnaps: number[];
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export function setEmblaContext(config: EmblaContext): EmblaContext {
|
||||
setContext(EMBLA_CAROUSEL_CONTEXT, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function getEmblaContext(name = "This component") {
|
||||
if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) {
|
||||
throw new Error(`${name} must be used within a <Carousel.Root> component`);
|
||||
}
|
||||
return getContext<ReturnType<typeof setEmblaContext>>(EMBLA_CAROUSEL_CONTEXT);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user