initttt
This commit is contained in:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
||||
.zed
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
__pycache__
|
||||
.venv
|
||||
|
||||
# ignore generated log files
|
||||
**/logs/**.log
|
||||
**/logs/**.log.gz
|
||||
**/logs/**-audit.json
|
||||
|
||||
**/data/credentials/**
|
||||
**/testdocs/**
|
||||
|
||||
ot_res.json
|
||||
out.json
|
||||
payload.json
|
||||
|
||||
screenshots/*.jpeg
|
||||
screenshots/*.png
|
||||
screenshots/*.jpg
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
.svelte-kit
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
creds.md
|
||||
|
||||
onlydevs
|
||||
47
.env.example
Normal file
47
.env.example
Normal file
@@ -0,0 +1,47 @@
|
||||
APP_NAME=${{project.APP_NAME}}
|
||||
NODE_ENV=${{project.NODE_ENV}}
|
||||
LOG_LEVEL=${{project.LOG_LEVEL}}
|
||||
|
||||
REDIS_URL=${{project.REDIS_URL}}
|
||||
DATABASE_URL=${{project.DATABASE_URL}}
|
||||
|
||||
INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
|
||||
DEBUG_KEY=${{project.DEBUG_KEY}}
|
||||
|
||||
PUBLIC_URL=${{project.PUBLIC_URL}}
|
||||
|
||||
PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}}
|
||||
APP_BUILDER_API_URL=${{project.APP_BUILDER_API_URL}}
|
||||
APP_BUILDER_ASSETS_PUBLIC_URL=${{project.APP_BUILDER_ASSETS_PUBLIC_URL}}
|
||||
|
||||
CLIENT_DOWNLOADED_APK_NAME=${{project.CLIENT_DOWNLOADED_APK_NAME}}
|
||||
|
||||
MOBILE_APP_API_URL=${{project.MOBILE_APP_API_URL}}
|
||||
|
||||
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
|
||||
BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}}
|
||||
|
||||
TWOFA_SECRET=${{project.TWOFA_SECRET}}
|
||||
TWO_FA_SESSION_EXPIRY_MINUTES=${{project.TWO_FA_SESSION_EXPIRY_MINUTES}}
|
||||
TWO_FA_REQUIRED_HOURS=${{project.TWO_FA_REQUIRED_HOURS}}
|
||||
|
||||
DEFAULT_ADMIN_EMAIL=${{project.DEFAULT_ADMIN_EMAIL}}
|
||||
DEFAULT_ADMIN_PASSWORD=${{project.DEFAULT_ADMIN_PASSWORD}}
|
||||
|
||||
OTEL_SERVICE_NAME=${{project.OTEL_SERVICE_NAME}}
|
||||
OTEL_TRACES_EXPORTER=${{project.OTEL_TRACES_EXPORTER}}
|
||||
OTEL_EXPORTER_OTLP_HTTP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_HTTP_ENDPOINT}}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}}
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
|
||||
OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}}
|
||||
|
||||
R2_BUCKET_NAME=${{project.R2_BUCKET_NAME}}
|
||||
R2_REGION=${{project.R2_REGION}}
|
||||
R2_ENDPOINT=${{project.R2_ENDPOINT}}
|
||||
R2_ACCESS_KEY=${{project.R2_ACCESS_KEY}}
|
||||
R2_SECRET_KEY=${{project.R2_SECRET_KEY}}
|
||||
R2_PUBLIC_URL=${{project.R2_PUBLIC_URL}}
|
||||
|
||||
MAX_FILE_SIZE=${{project.MAX_FILE_SIZE}}
|
||||
ALLOWED_MIME_TYPES=${{project.ALLOWED_MIME_TYPES}}
|
||||
ALLOWED_EXTENSIONS=${{project.ALLOWED_EXTENSIONS}}
|
||||
84
.gitignore
vendored
Normal file
84
.gitignore
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
.zed
|
||||
|
||||
mobile/**/*.iml
|
||||
mobile/**/.gradle
|
||||
mobile/**/local.properties
|
||||
mobile/**/.idea/caches
|
||||
mobile/**/.idea/libraries
|
||||
mobile/**/.idea/modules.xml
|
||||
mobile/**/.idea/workspace.xml
|
||||
mobile/**/.idea/navEditor.xml
|
||||
mobile/**/.idea/assetWizardSettings.xml
|
||||
mobile/**/.DS_Store
|
||||
mobile/**/build
|
||||
mobile/**/captures
|
||||
mobile/**/.externalNativeBuild
|
||||
mobile/**/.cxx
|
||||
mobile/**/local.properties
|
||||
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
__pycache__
|
||||
.venv
|
||||
|
||||
secret.md
|
||||
|
||||
# ignore generated log files
|
||||
**/logs/**.log
|
||||
**/logs/**.log.gz
|
||||
**/logs/**-audit.json
|
||||
|
||||
**/data/credentials/**
|
||||
**/testdocs/**
|
||||
|
||||
scripts/whatsapp.req.sh
|
||||
|
||||
ot_res.json
|
||||
out.json
|
||||
payload.json
|
||||
|
||||
screenshots/*.jpeg
|
||||
screenshots/*.png
|
||||
screenshots/*.jpg
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
.svelte-kit
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
**.apk
|
||||
|
||||
creds.md
|
||||
|
||||
onlydevs
|
||||
198
AGENTS.md
Normal file
198
AGENTS.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# AGENTS.md
|
||||
|
||||
This document defines the laws, principles, and rule sets that govern this codebase. Any agent or developer making changes has to adhere to these rules.
|
||||
|
||||
**Before starting off** — Read the README.md file to understand the project's goals and objectives.
|
||||
|
||||
---
|
||||
|
||||
## Agent Rules (Override Everything)
|
||||
|
||||
1. **No testing by yourself** — All testing is done by the team.
|
||||
2. **No assumptions about code or domain logic** — Always confirm and be sure before making changes.
|
||||
3. **No running scripts** — Do not run build, dev, test, or migrate scripts unless explicitly approved.
|
||||
4. **No touching migration files** — Do not mess with the `migrations` sql dir, as those are generated manually via drizzle orm
|
||||
|
||||
More rules are only to be added by the human, in case such a suggestion becomes viable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
- **Monorepo**: Turbo repo
|
||||
- **Package Manager**: pnpm
|
||||
- **Language**: TypeScript everywhere
|
||||
- **Node**: >= 24
|
||||
|
||||
### Applications
|
||||
|
||||
| App | Purpose |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/main` | SvelteKit UI — the primary user-facing application |
|
||||
| `apps/processor` | Hono web server — intended for asynchronous processing (jobs, workers). Currently minimal; structure is evolving. |
|
||||
|
||||
### Packages
|
||||
|
||||
Packages live under `packages/` and are **standalone, modular** pieces consumed by apps:
|
||||
|
||||
| Package | Purpose |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `@pkg/logic` | Domain logic (DDD, controllers, repositories) |
|
||||
| `@pkg/db` | Drizzle schema, database access |
|
||||
| `@pkg/logger` | Logging, `getError` for error construction |
|
||||
| `@pkg/result` | Result type, `ERROR_CODES`, `tryCatch` |
|
||||
| `@pkg/keystore` | Redis instance (sessions, 2FA, etc.) |
|
||||
| `@pkg/settings` | App settings / env config |
|
||||
|
||||
### Data Stores
|
||||
|
||||
- **PostgreSQL (Drizzle)** — Primary relational data (auth, users, accounts, etc.)
|
||||
- **Redis (Valkey)** — Sessions, 2FA verification state (via `@pkg/keystore`)
|
||||
|
||||
Additional stores (NoSQL DBs, R2, etc.) may be introduced later. Follow existing domain patterns when adding new data access.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Logic Package: DDD + Layered Architecture
|
||||
|
||||
The `@pkg/logic` package contains **all domain logic**. It follows:
|
||||
|
||||
1. **Domain-Driven Design (DDD)** — Bounded contexts as domains
|
||||
2. **Layered architecture** — Clear separation of concerns
|
||||
3. **Result-style error handling** — Errors as values; avoid try-catch in domain code
|
||||
|
||||
### Domain Structure
|
||||
|
||||
Each domain is a folder under `packages/logic/domains/`:
|
||||
|
||||
```
|
||||
domains/
|
||||
<domain-name>/
|
||||
data.ts # Types, schemas (Valibot)
|
||||
repository.ts # Data access
|
||||
controller.ts # Use cases / application logic
|
||||
errors.ts # Domain-specific error constructors (using getError)
|
||||
```
|
||||
|
||||
The logic package is **pure domain logic** — no HTTP routes or routers. API exposure is handled by the main app via SvelteKit remote functions. Auth uses `config.base.ts` with better-auth. Add new domains as needed; mirror existing patterns.
|
||||
|
||||
### Path Aliases (logic package)
|
||||
|
||||
- `@/*` → `./*`
|
||||
- `@domains/*` → `./domains/*`
|
||||
- `@core/*` → `./core/*`
|
||||
|
||||
### Flow Execution Context
|
||||
|
||||
Domain operations receive a `FlowExecCtx` (`fctx`) for tracing and audit:
|
||||
|
||||
```ts
|
||||
type FlowExecCtx = {
|
||||
flowId: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Error Handling: Result Pattern (neverthrow)
|
||||
|
||||
Errors are **values**, not exceptions. The codebase uses Result-style handling.
|
||||
|
||||
### Current Conventions
|
||||
|
||||
1. **Logic package** — Uses `neverthrow` (`ResultAsync`, `okAsync`, `errAsync`) for async operations that may fail.
|
||||
2. **`@pkg/result`** — Provides `Result<T, Err>`, `ERROR_CODES`, and `tryCatch()`. The `Result` type is legacy; So don't reach for it primarily.
|
||||
3. Use `ERROR_CODES` for consistent error codes.
|
||||
4. **`getError()`** — From `@pkg/logger`. Use at boundaries when converting a thrown error to an `Err` object:
|
||||
`return getError({ code: ERROR_CODES.XXX, message: "...", description: "...", detail: "..." }, e)`.
|
||||
5. **Domain errors** — Each domain has an `errors.ts` that exports error constructors built with `getError`. Use these instead of ad-hoc error objects.
|
||||
6. **Check before use** — Always check `result.isOk()` / `result.isErr()` before using `result.value`; never assume success.
|
||||
|
||||
### Err Shape
|
||||
|
||||
```ts
|
||||
type Err = {
|
||||
flowId?: string;
|
||||
code: string;
|
||||
message: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
actionable?: boolean;
|
||||
error?: any;
|
||||
// Flexible, but more "defined base fields" in the future
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend (Main App)
|
||||
|
||||
The main app is a **SvelteKit** application with a domain-driven UI structure.
|
||||
|
||||
### Structure
|
||||
|
||||
- **Routes**: File-based routing under `src/routes/`. Layout groups (e.g. `(main)`, `auth`) wrap related pages.
|
||||
- **Domain-driven UI**: Feature code lives under `src/lib/domains/<domain>/` — each domain has its own folder with view models, components, and remote functions.
|
||||
- **View Model (VM) pattern**: Domain logic and state for a screen live in `*.vm.svelte.ts` classes. VMs hold reactive state (`$state`), orchestrate remote function calls, and expose methods. Pages import and use a VM instance.
|
||||
|
||||
### SvelteKit Remote Functions
|
||||
|
||||
The main app uses **SvelteKit remote functions** as the primary API layer — replacing Hono routers in the logic package. Each domain has a `*.remote.ts` file that exposes `query` (reads) and `command` (writes) functions, called directly from VMs. Auth context and `FlowExecCtx` are built inside each remote function from `event.locals` via helpers in `$lib/core/server.utils`.
|
||||
|
||||
Naming convention: `*SQ` for queries, `*SC` for commands.
|
||||
|
||||
### Global Stores
|
||||
|
||||
Shared state (`user`, `session`, `breadcrumbs`) lives in `$lib/global.stores.ts`.
|
||||
|
||||
### Conventions
|
||||
|
||||
- Pages are thin: they mount a VM, render components, and wire up lifecycle.
|
||||
- VMs own async flows, polling, and error handling for their domain.
|
||||
- VMs call remote functions directly; remote functions invoke logic controllers.
|
||||
- UI components under `$lib/components/` are shared; domain-specific components live in `$lib/domains/<domain>/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Processor App
|
||||
|
||||
The processor is a **Hono** server intended for **background work** and async jobs. Its structure is still evolving and it is to be updated soon.
|
||||
|
||||
When logic is added, processing logic should live under `src/domains/<domain>/` and call into `@pkg/logic` controllers and repositories.
|
||||
|
||||
---
|
||||
|
||||
## 6. Observability
|
||||
|
||||
The stack uses **OpenTelemetry** end-to-end for traces, logs, and metrics, shipped to a **SigNoz** instance (via OTel Collector).
|
||||
|
||||
### How it fits together
|
||||
|
||||
- **`apps/main`** bootstraps the OTel SDK in `instrumentation.server.ts` (auto-instrumentation via `@opentelemetry/sdk-node`). SvelteKit's `tracing` and `instrumentation` experimental flags wire this into the request lifecycle.
|
||||
- **`@pkg/logger`** ships Winston logs to OTel via `OpenTelemetryTransportV3` — logs are correlated with active traces automatically.
|
||||
- **`@pkg/logic/core/observability.ts`** provides two tracing helpers for domain code:
|
||||
- `traceResultAsync` — wraps a `ResultAsync` operation in an OTel span. Use this in repositories and controllers.
|
||||
- `withFlowSpan` — lower-level span wrapper for non-Result async code.
|
||||
- Both helpers accept `fctx` and stamp spans with `flow.id`, `flow.user_id`, and `flow.session_id` for end-to-end trace correlation.
|
||||
|
||||
### Convention
|
||||
|
||||
When adding new domain operations in repositories or controllers, wrap them with `traceResultAsync`. Keep span names consistent and descriptive (e.g. `"user.getUserInfo"`). Do not add ad-hoc spans outside these helpers.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validation & Schemas
|
||||
|
||||
- **Valibot** is used for schema validation in the logic package and in remote function input.
|
||||
- Domain data types are defined in `data.ts` per domain.
|
||||
- Use `v.InferOutput<typeof Schema>` for TypeScript types.
|
||||
- Remote functions pass Valibot schemas to `query()` and `command()` for input validation.
|
||||
|
||||
---
|
||||
|
||||
## 8. Package Naming
|
||||
|
||||
- Apps: `@apps/*` (e.g. `@apps/main`, `@apps/processor`)
|
||||
- Packages: `@pkg/*` (e.g. `@pkg/logic`, `@pkg/db`, `@pkg/logger`)
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Illusory IOTAM
|
||||
|
||||
This is the source code for a SaaS project with purposes to basically offer the users the ability to use specific Android applications, but instead of the apps running on their own phone, they run on our hosted Docker-Android instances.
|
||||
|
||||
Right now the project is in alpha testing phase, so it is subject to change (greenfield project)
|
||||
|
||||
---
|
||||
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,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user