first commit

This commit is contained in:
Antoine Pelletier 2026-07-30 06:26:31 +02:00
commit 52363d59ac
225 changed files with 21984 additions and 0 deletions

2
.env Normal file
View file

@ -0,0 +1,2 @@
# This file is used by dbmate, and by the sqlx macros at compile time.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_events?sslmode=disable

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
config.yml
config.yaml

2827
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

32
Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
[package]
name = "events"
version = "0.1.0"
edition = "2024"
[dependencies]
aide = { version = "0.15.1", features = [
"axum-json",
"axum-query",
"http",
"macros",
"scalar",
] }
async-trait = "0.1.89"
axum = { version = "0.8.9", features = ["macros"] }
axum-extra = { version = "0.12.6", features = ["cookie-signed", "cookie-key-expansion"] }
chrono = { version = "0.4.45", features = ["serde"] }
config = "0.15.23"
schemars = { version = "0.9.0", features = ["chrono04"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
sqlx = { version = "0.8.6", features = [
"postgres",
"chrono",
"json",
"runtime-tokio",
] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "signal"] }
tower-http = { version = "0.6.10", features = ["fs"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }

156
README.md Normal file
View file

@ -0,0 +1,156 @@
# Events
Starting point for an AGEPoly web project: a **Rust** backend (axum + sqlx + aide) serving a
**Vue 3** frontend (TypeScript + vue-query + shadcn-vue), on **PostgreSQL** with **dbmate**
migrations.
The template is deliberately almost empty: one table (`items`), one route
(`GET /api/items`) and one page (home) that displays it. They exist to show how the layers
fit together — rename them, or delete them once your own code is in place.
## What you get
- Layered backend (api / core / services), see [Backend](#backend)
- An OpenAPI document generated from the code, and **TypeScript types generated from it**:
the frontend cannot call an endpoint that does not exist
- i18n (fr/en) on both sides, localized strings stored as JSONB
- `dev-db/`: postgres + adminer in docker for development
- Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui`
- A single binary in production: the backend serves the built frontend
There is **no authentication** in this template: add whatever the project needs.
## Bootstrap a new project
```bash
cp -r template /path/to/my-project && cd /path/to/my-project
git init
# 1. Rename the crate and the database (events -> my-project)
./rename.sh my-project "My Project" && rm rename.sh
# 2. Start the development database
cd dev-db && docker compose up -d && cd ..
psql -h localhost -U postgres -c 'CREATE DATABASE my_project'
dbmate up
psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data
# 3. Configure the app
cp config.example.yml config.yml
# 4. Run the backend (port 3000)
cargo run
# 5. Run the frontend (port 5000, proxies /api to the backend)
cd frontend && npm install && npm run dev
```
Open http://localhost:5000. The api documentation is on http://localhost:3000/api/docs.
`config.yml` is gitignored: it is where the secrets go. `config.example.yml` documents every
key, keep it up to date. Every value can also come from the environment
(`APP__POSTGRES__PASSWORD=...`), which is how the app is configured in production.
## Layout
```
├── src/ backend
│ ├── api/ http layer: routes, extractors, OpenAPI
│ ├── core/ business logic, independent of axum and sqlx
│ │ ├── controller/ what the app can do
│ │ ├── models/ domain types
│ │ └── repositories/ traits describing what the core needs from the storage
│ ├── services/ implementations of the repositories (postgres/sqlx)
│ └── utils/ configuration
├── db/
│ ├── migrations/ dbmate migrations
│ ├── schema.sql dump regenerated by dbmate, do not edit by hand
│ └── seed.sql development data
├── dev-db/ postgres + adminer for development
└── frontend/
└── src/
├── components/ shared components (ui/ = shadcn-vue)
├── views/ one component per route
├── router/ route table
├── services/ api/ (one file per domain area) + i18n
├── lib/api.d.ts generated from the backend, never edited by hand
├── utils/types.ts shorthands over the generated schemas
└── locales/ fr.yml / en.yml
```
## Backend
Requests flow through three layers, each one only knowing the next:
```
api (axum handler) -> core/controller -> core/repositories -> services/database
status codes business logic trait sqlx + postgres
domain models
```
The point of the repository traits is that the core never depends on sqlx: you can add
another implementation (a mock in tests, another storage) without touching the logic.
Handlers stay thin — extract the controller, call it, map the error to a status code — and
their OpenAPI documentation sits right next to them (`fn *_docs`).
### Adding an entity
1. `dbmate n create_things` and write the migration, then `dbmate up`
2. `src/core/models/thing.rs`: the domain types
3. `src/core/repositories/things_repository.rs`: the trait, added to `DatabaseRepository`
4. `src/services/database/things.rs`: the sqlx implementation
5. `src/core/controller/things.rs`: the logic, plus a `ThingsControllerError` if needed
6. `src/api/things.rs`: the handlers and their docs, mounted in `src/api/mod.rs`
7. `cd frontend && npm run openapi` to regenerate the types, then write the service and the view
The `Item` entity follows exactly these steps: copy it.
Request bodies are best kept separate from the domain models (an `api/models.rs` holding
the `...Api` structs and their `Into<Domain>` impls), so that the public contract does not
change every time a domain model does.
### sqlx and compilation
`query!`/`query_as!` check the sql against a **real database at compile time**, so the
development database must be up and migrated for `cargo build` to work. `DATABASE_URL` is
read from `.env`.
To build without a database (CI, docker image), commit the offline data:
```bash
cargo install sqlx-cli
cargo sqlx prepare # writes .sqlx/, commit it
```
### Migrations
```bash
dbmate n add_something # creates db/migrations/<timestamp>_add_something.sql
dbmate up # applies, and regenerates db/schema.sql
dbmate rollback # undoes the last migration (write your `migrate:down`!)
```
## Frontend
```bash
npm run dev # dev server on :5000, /api proxied to the backend on :3000
npm run build # type-check + build into dist/
npm run type-check
npm run lint
npm run format
npm run openapi # regenerate src/lib/api.d.ts from the running backend
```
Rules of thumb:
- views never call `fetch`: they use a hook from `services/api/`, which returns a vue-query
query or mutation. Caching, loading and error states come for free.
- mutations update the cache in `onSuccess` so the ui reacts immediately.
- texts live in `locales/*.yml` and are used through `$t('key')`, never hardcoded.
- add a shadcn-vue component with `npx shadcn-vue@latest add <name>`.
## Production
`cargo build --release`, `npm run build`, then point `frontend_dir` at the built
`frontend/dist`: the backend serves the static files and falls back on `index.html` so the
vue router keeps working on a page reload. Only one process to deploy.

20
build.rs Normal file
View file

@ -0,0 +1,20 @@
use std::process::Command;
/// Exposes the current commit and the crate name to the code through `env!()`
fn main() {
let git_hash = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|hash| hash.trim().chars().take(8).collect::<String>())
.unwrap_or_else(|| "unknown".to_owned());
println!("cargo:rustc-env=GIT_HASH={git_hash}");
println!(
"cargo:rustc-env=CRATE_NAME={}",
env!("CARGO_PKG_NAME").replace("-", "_")
);
println!("cargo:rerun-if-changed=.git/HEAD");
}

25
config.example.yml Normal file
View file

@ -0,0 +1,25 @@
# Copy to config.yml (gitignored) and adapt.
# Every value can also be given as an environment variable, e.g. APP__SERVER__PORT=3000
server:
address: 0.0.0.0
port: 3000
postgres:
host: localhost
port: 5432
user: postgres
password: postgres
name: events
auth:
# Signs the session cookie. Use a long random string in production.
secret: change-me-to-a-long-random-string
# Optional: ensured to exist (and to be admin) on startup, so you always have an
# account to log in with (there is no password: login is by email only).
dev_user:
firstname: Dev
name: User
email: dev@example.com
# Directory containing the built frontend (frontend/dist after `npm run build`)
frontend_dir: frontend/dist

View file

@ -0,0 +1,12 @@
-- migrate:up
CREATE TABLE users (
id SERIAL PRIMARY KEY,
firstname TEXT NOT NULL,
"name" TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
units INTEGER[] NOT NULL DEFAULT '{}',
admin BOOLEAN NOT NULL DEFAULT false
);
-- migrate:down
DROP TABLE IF EXISTS users;

View file

@ -0,0 +1,22 @@
-- migrate:up
CREATE TABLE events (
id SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
description TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
setup_start_date DATE,
setup_end_date DATE,
location TEXT NOT NULL
);
-- The organizing committee: which users are attached to which event.
CREATE TABLE event_committee (
event_id INTEGER NOT NULL REFERENCES events (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
PRIMARY KEY (event_id, user_id)
);
-- migrate:down
DROP TABLE IF EXISTS event_committee;
DROP TABLE IF EXISTS events;

View file

@ -0,0 +1,9 @@
-- migrate:up
-- A member's role/position can change from one event to another, so it lives
-- on the join row rather than on the user.
ALTER TABLE event_committee ADD COLUMN role TEXT NOT NULL DEFAULT '';
ALTER TABLE event_committee ALTER COLUMN role DROP DEFAULT;
-- migrate:down
ALTER TABLE event_committee DROP COLUMN role;

View file

@ -0,0 +1,8 @@
-- migrate:up
-- Freeform site plan for the event, drawn on a map: a GeoJSON FeatureCollection.
ALTER TABLE events
ADD COLUMN plan JSONB NOT NULL DEFAULT '{"type": "FeatureCollection", "features": []}'::jsonb;
-- migrate:down
ALTER TABLE events DROP COLUMN plan;

198
db/schema.sql Normal file
View file

@ -0,0 +1,198 @@
\restrict dbmate
-- Dumped from database version 18.3
-- Dumped by pg_dump version 18.4
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: event_committee; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.event_committee (
event_id integer NOT NULL,
user_id integer NOT NULL,
role text NOT NULL
);
--
-- Name: events; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.events (
id integer NOT NULL,
name text NOT NULL,
description text NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
setup_start_date date,
setup_end_date date,
location text NOT NULL,
plan jsonb DEFAULT '{"type": "FeatureCollection", "features": []}'::jsonb NOT NULL
);
--
-- Name: events_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.events_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.events_id_seq OWNED BY public.events.id;
--
-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.schema_migrations (
version character varying NOT NULL
);
--
-- Name: users; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.users (
id integer NOT NULL,
firstname text NOT NULL,
name text NOT NULL,
email text NOT NULL,
units integer[] DEFAULT '{}'::integer[] NOT NULL,
admin boolean DEFAULT false NOT NULL
);
--
-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.users_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
--
-- Name: events id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.events_id_seq'::regclass);
--
-- Name: users id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
--
-- Name: event_committee event_committee_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.event_committee
ADD CONSTRAINT event_committee_pkey PRIMARY KEY (event_id, user_id);
--
-- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.events
ADD CONSTRAINT events_pkey PRIMARY KEY (id);
--
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_email_key UNIQUE (email);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: event_committee event_committee_event_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.event_committee
ADD CONSTRAINT event_committee_event_id_fkey FOREIGN KEY (event_id) REFERENCES public.events(id) ON DELETE CASCADE;
--
-- Name: event_committee event_committee_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.event_committee
ADD CONSTRAINT event_committee_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--
\unrestrict dbmate
--
-- Dbmate schema migrations
--
INSERT INTO public.schema_migrations (version) VALUES
('20260730000000'),
('20260730000001'),
('20260730002032'),
('20260730020605');

1
dev-db/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
db

25
dev-db/docker-compose.yml Normal file
View file

@ -0,0 +1,25 @@
# This compose file should only be used in development.
# Caution: the ports of db and adminer are open without any application-side protection
services:
db:
image: postgres:18.3-alpine
ports: ["5432:5432"]
environment:
- POSTGRES_PASSWORD=postgres
volumes:
- ./db:/var/lib/postgresql
adminer:
image: adminer
ports: ["8080:8080"]
environment:
- ADMINER_SERVERS={
"dev db":{
"driver":"pgsql",
"server":"db",
"username":"postgres",
"password":"postgres",
"db":""
}
}
volumes:
- ./login-servers.php:/var/www/html/plugins-enabled/login-servers.php

52
dev-db/login-servers.php Normal file
View file

@ -0,0 +1,52 @@
<?php
/**
* Display servers list from defined ADMINER_SERVERS variable.
* @link https://www.adminer.org/plugins/#use
* @author https://github.com/garis-space
*/
class AdminerLoginServers {
/**
* Set servers from environment variable
* Example:
* $_ENV['ADMINER_SERVERS'] = '{
* "Server 1":{"driver":"pgsql","server":"","username":"","password":"","db":""},
* "Server 2":{"driver":"pgsql","server":"","username":"","password":"","db":""}
* }';
*/
function __construct() {
$this->servers = array();
if (!empty($_ENV['ADMINER_SERVERS'])) {
$this->servers = json_decode($_ENV['ADMINER_SERVERS'], true);
}
if (!empty($_POST["auth"]["custom_server"])) {
$key = $_POST["auth"]["custom_server"];
$_POST["auth"]["driver"] = $this->servers[$key]["driver"];
$_POST["auth"]["server"] = $this->servers[$key]["server"];
$_POST["auth"]["username"] = $this->servers[$key]["username"];
$_POST["auth"]["password"] = $this->servers[$key]["password"];
$_POST["auth"]["db"] = $this->servers[$key]["db"];
}
}
function loginFormField($name, $heading, $value) {
if ($name == 'driver') {
return '<tr><th>Driver<td>' . $value;
} elseif ($name == 'server') {
return '<tr><th>Host<td>' . $value;
} elseif ($name == 'db' && $_ENV['ADMINER_SERVERS'] != '') {
$out = $heading . $value;
$out .= '<tr><th><td>or';
$out .= '<tr><th>Server<td><select name="auth[custom_server]">';
$out .= '<option value="" selected>--</option>';
foreach ($this->servers as $serverName => $serverConfig) {
$out .= '<option value="' . htmlspecialchars($serverName) . '">' . htmlspecialchars($serverName) . '</option>';
}
$out .= '</select>';
return $out;
}
}
}
return new AdminerLoginServers();

8
frontend/.editorconfig Normal file
View file

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

30
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View file

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

21
frontend/components.json Normal file
View file

@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/styles/tailwind.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

1
frontend/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

32
frontend/eslint.config.ts Normal file
View file

@ -0,0 +1,32 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
{
// Must come last to win over `flat/essential`.
// shadcn-vue components (Button, Card, ...) are single word by design.
name: 'app/rules',
files: ['**/*.{ts,mts,tsx,vue}'],
rules: {
'vue/multi-word-component-names': 'off',
},
},
)

18
frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/src/styles/tailwind.css" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gestion d'évènements</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6697
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

64
frontend/package.json Normal file
View file

@ -0,0 +1,64 @@
{
"name": "events-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/",
"openapi": "openapi-typescript http://localhost:3000/api/docs/private/api.json -o ./src/lib/api.d.ts"
},
"dependencies": {
"@lucide/vue": "^1.26.0",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^6.1.29",
"@vueuse/core": "^14.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"http-status-ts": "^2.0.1",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"openapi-fetch": "^0.17.0",
"postcss": "^8.5.14",
"proj4": "^2.21.0",
"proj4leaflet": "^1.0.2",
"reka-ui": "^2.10.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"vue": "^3.5.18",
"vue-i18n": "^11.4.2",
"vue-router": "^5.0.7",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^11.2.1",
"@tsconfig/node22": "^22.0.5",
"@types/leaflet": "^1.9.21",
"@types/leaflet-draw": "^1.0.13",
"@types/node": "^25.8.0",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.4.0",
"eslint-plugin-vue": "~10.9.1",
"jiti": "^2.7.0",
"npm-run-all2": "^8.0.4",
"openapi-typescript": "^7.13.0",
"prettier": "3.8.3",
"typescript": "~5.9.3",
"vite": "^8.0.13",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^3.2.9"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

16
frontend/src/App.vue Normal file
View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
import { SidebarProvider } from '@/components/ui/sidebar'
import { Toaster } from '@/components/ui/sonner'
import AppSidebar from './components/AppSidebar.vue'
import AppContent from './components/AppContent.vue'
</script>
<template>
<SidebarProvider>
<AppSidebar />
<AppContent />
</SidebarProvider>
<VueQueryDevtools />
<Toaster position="top-center" />
</template>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import { useSidebar } from './ui/sidebar'
const { setOpen } = useSidebar()
</script>
<template>
<main @click="setOpen(false)" class="py-5 px-7 min-h-full w-full">
<RouterView />
</main>
</template>

View file

@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Home, ShieldCheck, LogIn, LogOut } from '@lucide/vue'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { locales, setLocale } from '@/services/i18n'
import { useMe, useLogout } from '@/services/api/auth'
const { t, locale: currentLocale } = useI18n()
const router = useRouter()
const { isMobile, open, setOpen } = useSidebar()
const { data: me } = useMe()
const { mutate: logout } = useLogout()
const menu = computed(() => {
const items = [{ title: t('sidebar.home'), icon: Home, name: 'home' }]
if (me.value?.admin) {
items.push({ title: t('sidebar.admin'), icon: ShieldCheck, name: 'admin' })
}
return [{ title: t('sidebar.navigation'), content: items }]
})
const selectedLangFlag = computed(
() => locales.find((l) => l.lang === currentLocale.value)?.flag ?? locales[0].flag,
)
function navigate(name: string) {
setOpen(false)
router.push({ name })
}
function onAccountClick() {
setOpen(false)
if (me.value) {
logout()
} else {
router.push({ name: 'login' })
}
}
</script>
<template>
<Sidebar @click="setOpen(true)" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" @click.stop="navigate('home')">
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
<Home class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ $t('app.title') }}</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger class="w-fit" as-child>
<SidebarMenuButton @click.stop class="w-[3em] justify-center">
<span :class="'fi fi-' + selectedLangFlag"></span>
</SidebarMenuButton>
<DropdownMenuContent class="w-[3em]" :side="isMobile || open ? 'bottom' : 'right'">
<DropdownMenuItem
v-for="locale in locales"
:key="locale.lang"
@click="setLocale(locale.lang)"
class="justify-center"
>
<span :class="'fi fi-' + locale.flag + ' w-fit'"></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuTrigger>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup v-for="menuGroup in menu" :key="menuGroup.title">
<SidebarGroupLabel>{{ menuGroup.title }}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="menuEntry in menuGroup.content" :key="menuEntry.title">
<SidebarMenuButton asChild>
<a @click.stop="navigate(menuEntry.name)" class="cursor-pointer">
<component :is="menuEntry.icon" />
<span>{{ menuEntry.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton @click.stop="onAccountClick">
<component :is="me ? LogOut : LogIn" />
<span>{{ me ? me.firstname : $t('sidebar.login') }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
</template>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import type { NewEvent } from '@/utils/types'
const form = defineModel<Omit<NewEvent, 'committee' | 'plan'>>({ required: true })
const setupStartDate = computed({
get: () => form.value.setup_start_date ?? '',
set: (value: string) => {
form.value.setup_start_date = value || null
},
})
const setupEndDate = computed({
get: () => form.value.setup_end_date ?? '',
set: (value: string) => {
form.value.setup_end_date = value || null
},
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.name') }}</label>
<Input v-model="form.name" required />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.description') }}</label>
<Textarea v-model="form.description" required />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.start-date') }}</label>
<Input v-model="form.start_date" type="date" required />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.end-date') }}</label>
<Input v-model="form.end_date" type="date" required />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.setup-start-date') }}</label>
<Input v-model="setupStartDate" type="date" />
</div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.setup-end-date') }}</label>
<Input v-model="setupEndDate" type="date" />
</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium">{{ $t('admin.fields.location') }}</label>
<Input v-model="form.location" required />
</div>
</div>
</template>

View file

@ -0,0 +1,613 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import type * as Leaflet from 'leaflet'
import 'leaflet/dist/leaflet.css'
import 'leaflet-draw/dist/leaflet.draw.css'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
// The editing toolbar (add/edit/delete shapes) only shows up for admins; a
// public visitor sees the drawn plan read-only.
const { editable = false } = defineProps<{ editable?: boolean }>()
const geojson = defineModel<GeoJSON.FeatureCollection>({ required: true })
const { t } = useI18n()
const EPFL_CENTER: [number, number] = [46.5185, 6.5666]
const EPFL_INITIAL_ZOOM = 22
// EPFL's building outlines are only published through plan.epfl.ch's own
// (public, unauthenticated, but undocumented) tile service, in the Swiss
// national grid (EPSG:2056) rather than the usual Web Mercator one so the
// whole map has to use that grid to align the OSM base layer with the
// building tiles. Origin and per-zoom resolutions come straight from that
// service's own WMTS capabilities document (prod-plan-epfl-tiles*.epfl.ch/
// 1.0.0/WMTSCapabilities_prod_2056.xml), not guessed.
const SWISS_PROJ4_DEF =
'+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs'
const SWISS_GRID_ORIGIN: [number, number] = [2420000, 1350000]
// Actual tile data only goes up to index 29 (buildings) / 28 (base map,
// aerial). A couple more levels are appended so the map can still be zoomed
// in further than that: Leaflet then just upscales the deepest available
// tile instead of fetching a non-existent one (`maxNativeZoom` below).
const NATIVE_RESOLUTIONS = [
4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000, 1750, 1500, 1250, 1000, 750, 650, 500,
250, 100, 50, 20, 10, 5, 2.5, 2, 1.5, 1, 0.5, 0.25, 0.1, 0.05,
]
const EXTRA_ZOOM_LEVELS = 2
const SWISS_RESOLUTIONS = [
...NATIVE_RESOLUTIONS,
...Array.from(
{ length: EXTRA_ZOOM_LEVELS },
(_, i) => NATIVE_RESOLUTIONS[NATIVE_RESOLUTIONS.length - 1] / 2 ** (i + 1),
),
]
const MAX_ZOOM = SWISS_RESOLUTIONS.length - 1
const BUILDINGS_MAX_NATIVE_ZOOM = NATIVE_RESOLUTIONS.length - 1
const BASE_LAYERS_MAX_NATIVE_ZOOM = NATIVE_RESOLUTIONS.length - 2
// `{floorPath}` is "" for the base map and "{floor}/" for the buildings
// layer, matching the two real URL shapes observed on plan.epfl.ch:
// .../osm-wmts/default/{date}/2056/{z}/{row}/{col}.png
// .../batiments/default/{date}/{floor}/2056/{z}/{row}/{col}.png
// Note the path is TileMatrix/TileRow/TileCol (OGC WMTS REST convention,
// i.e. z/y/x) rather than the more common z/x/y "slippy map" order.
const EPFL_TILES_URL =
'https://prod-plan-epfl-tiles{s}.epfl.ch/1.0.0/{layer}/default/{date}/{floorPath}2056/{z}/{y}/{x}.png'
const EPFL_TILE_SUBDOMAINS = ['0', '1', '2', '3', '4']
// The aerial/satellite imagery is served separately, by the official (public,
// documented) Swiss geoportal, on the same grid (origin and resolutions
// verified against its own WMTS capabilities document) but NOT the same URL
// convention: swisstopo's REST template is TileMatrix/TileCol/TileRow, i.e.
// z/x/y ("slippy map" order), unlike EPFL's own proxy above (z/y/x).
const AERIAL_TILES_URL =
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage-product/default/current/2056/{z}/{x}/{y}.jpeg'
interface EpflTileLayerOptions extends Leaflet.TileLayerOptions {
layer: string
date: string
floorPath: string
}
const DEFAULT_COLOR = '#3388ff'
const DASHED_PATTERN = '8, 8'
const floor = ref(0)
const backgroundPanelOpen = ref(false)
const showMap = ref(true)
const showAerial = ref(false)
const aerialOpacity = ref(1)
// Style applied to newly drawn shapes.
const drawColor = ref(DEFAULT_COLOR)
const drawDashed = ref(false)
const styleDialogOpen = ref(false)
const styleForm = reactive({ color: DEFAULT_COLOR, dashed: false })
const annotationDialogOpen = ref(false)
const annotationKind = ref<'text' | 'icon'>('text')
const annotationForm = reactive({ text: '', svg: '' })
const mapContainer = ref<HTMLDivElement>()
let map: Leaflet.Map | undefined
let osmLayer: Leaflet.TileLayer | undefined
let aerialLayer: Leaflet.TileLayer | undefined
let buildingsLayer: Leaflet.TileLayer | undefined
let drawnItems: Leaflet.FeatureGroup | undefined
let LeafletLib: typeof Leaflet | undefined
function floorOf(feature: GeoJSON.Feature): number {
const value = feature.properties?.floor
return typeof value === 'number' ? value : 0
}
function escapeHtml(value: string): string {
const div = document.createElement('div')
div.textContent = value
return div.innerHTML
}
// Minimal SVG sanitizer: strips <script>/<foreignObject> and any event
// handler or javascript: URI, since this markup gets rendered for every
// visitor of the (public) plan. Not a full sanitizer, but covers the
// common XSS vectors for a narrow "paste an icon" use case.
function sanitizeSvg(svgString: string): string | null {
let doc: Document
try {
doc = new DOMParser().parseFromString(svgString, 'image/svg+xml')
} catch {
return null
}
const root = doc.documentElement
if (!root || root.nodeName.toLowerCase() !== 'svg' || doc.querySelector('parsererror')) {
return null
}
const stripDangerousAttrs = (el: Element) => {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase()
const value = attr.value.trim().toLowerCase()
if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
el.removeAttribute(attr.name)
}
}
}
const clean = (node: Element) => {
stripDangerousAttrs(node)
for (const child of Array.from(node.children)) {
const tag = child.tagName.toLowerCase()
if (tag === 'script' || tag === 'foreignobject') {
child.remove()
continue
}
clean(child)
}
}
clean(root)
return new XMLSerializer().serializeToString(root)
}
function annotationHtml(properties: GeoJSON.GeoJsonProperties): string {
if (properties?.kind === 'text') {
const color = typeof properties.color === 'string' ? properties.color : DEFAULT_COLOR
return `<div class="plan-annotation-text" style="color:${color}">${escapeHtml(String(properties.text ?? ''))}</div>`
}
if (typeof properties?.emoji === 'string') {
return `<div class="plan-annotation-icon">${escapeHtml(properties.emoji)}</div>`
}
// Re-sanitize on every render, not just at creation time: the stored
// `plan` blob is opaque to the backend (no server-side validation), so
// this is the actual trust boundary anything that ends up in the
// database must still be safe to render for every future visitor.
const svg = typeof properties?.svg === 'string' ? (sanitizeSvg(properties.svg) ?? '') : ''
return `<div class="plan-annotation-icon">${svg}</div>`
}
function makeAnnotationIcon(properties: GeoJSON.GeoJsonProperties): Leaflet.DivIcon {
return LeafletLib!.divIcon({
html: annotationHtml(properties),
className: 'plan-annotation-marker',
iconSize: [32, 32],
iconAnchor: [16, 16],
})
}
type StyledLayer = Leaflet.Path & { feature?: GeoJSON.Feature }
let editingStyleLayer: StyledLayer | undefined
function hasSetStyle(layer: Leaflet.Layer): layer is StyledLayer {
return typeof (layer as unknown as Partial<Leaflet.Path>).setStyle === 'function'
}
function attachStyleClickHandler(layer: Leaflet.Layer) {
if (!editable || !hasSetStyle(layer)) return
layer.on('click', () => openStyleEditor(layer))
}
function openStyleEditor(layer: StyledLayer) {
editingStyleLayer = layer
const properties = layer.feature?.properties ?? {}
styleForm.color = typeof properties.color === 'string' ? properties.color : DEFAULT_COLOR
styleForm.dashed = !!properties.dashArray
styleDialogOpen.value = true
}
function applyStyleToSelected() {
if (!editingStyleLayer) return
const dashArray = styleForm.dashed ? DASHED_PATTERN : undefined
editingStyleLayer.setStyle({ color: styleForm.color, dashArray })
editingStyleLayer.feature = {
type: 'Feature',
geometry: editingStyleLayer.feature?.geometry ?? (null as never),
properties: {
...editingStyleLayer.feature?.properties,
color: styleForm.color,
dashArray: dashArray ?? null,
},
}
styleDialogOpen.value = false
emitCurrentShapes()
}
function openAnnotationDialog(kind: 'text' | 'icon') {
annotationKind.value = kind
annotationForm.text = ''
annotationForm.svg = ''
annotationDialogOpen.value = true
}
function confirmAnnotation() {
if (!map || !LeafletLib || !drawnItems) return
const L = LeafletLib
const properties: GeoJSON.GeoJsonProperties = { floor: floor.value, kind: annotationKind.value, color: drawColor.value }
if (annotationKind.value === 'text') {
if (!annotationForm.text.trim()) return
properties.text = annotationForm.text.trim()
} else {
const raw = annotationForm.svg.trim()
if (!raw) return
if (raw.startsWith('<')) {
const cleaned = sanitizeSvg(raw)
if (!cleaned) {
toast.error(t('admin.invalid-svg'))
return
}
properties.svg = cleaned
} else {
// Not SVG markup: treat it as plain text (e.g. an emoji).
properties.emoji = raw
}
}
const marker = L.marker(map.getCenter(), {
draggable: true,
pane: 'drawingsPane',
icon: makeAnnotationIcon(properties),
}) as Leaflet.Marker & { feature?: GeoJSON.Feature }
marker.feature = { type: 'Feature', properties, geometry: null as never }
marker.on('dragend', emitCurrentShapes)
drawnItems.addLayer(marker)
annotationDialogOpen.value = false
emitCurrentShapes()
}
function loadFloorIntoDrawnItems() {
if (!drawnItems || !LeafletLib) return
const L = LeafletLib
drawnItems.clearLayers()
const features = (geojson.value.features ?? []).filter((f) => floorOf(f) === floor.value)
const collection: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }
L.geoJSON(collection, {
pointToLayer: (feature, latlng) => {
if (feature.properties?.kind === 'text' || feature.properties?.kind === 'icon') {
const marker = L.marker(latlng, {
draggable: editable,
pane: 'drawingsPane',
icon: makeAnnotationIcon(feature.properties),
})
if (editable) marker.on('dragend', emitCurrentShapes)
return marker
}
return L.marker(latlng, { pane: 'drawingsPane' })
},
style: (feature) => ({
pane: 'drawingsPane',
color: typeof feature?.properties?.color === 'string' ? feature.properties.color : DEFAULT_COLOR,
dashArray:
typeof feature?.properties?.dashArray === 'string' ? feature.properties.dashArray : undefined,
}),
}).eachLayer((layer) => {
attachStyleClickHandler(layer)
drawnItems?.addLayer(layer)
})
}
function emitCurrentShapes() {
if (!drawnItems) return
const otherFloors = (geojson.value.features ?? []).filter((f) => floorOf(f) !== floor.value)
const currentFloor = (drawnItems.toGeoJSON() as GeoJSON.FeatureCollection).features
geojson.value = {
type: 'FeatureCollection',
features: [...otherFloors, ...currentFloor],
}
}
onMounted(async () => {
// leaflet-draw and proj4leaflet are old-style plugins that patch the
// global `L` object rather than importing leaflet themselves, so `window.L`
// must be set before they load. Dynamic imports (unlike static ones) run in
// the order awaited, which is what makes this ordering reliable.
const L = (await import('leaflet')).default
LeafletLib = L
;(window as unknown as { L: typeof L }).L = L
await import('proj4leaflet')
// Leaflet's default marker icon paths break under bundlers: point them at
// the actual bundled asset URLs instead.
delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
})
if (!mapContainer.value) return
const swissCrs = new L.Proj.CRS('EPSG:2056', SWISS_PROJ4_DEF, {
origin: SWISS_GRID_ORIGIN,
resolutions: SWISS_RESOLUTIONS,
})
map = L.map(mapContainer.value, {
crs: swissCrs as unknown as Leaflet.CRS,
minZoom: 0,
maxZoom: MAX_ZOOM,
}).setView(EPFL_CENTER, EPFL_INITIAL_ZOOM)
// Dedicated panes (rather than each TileLayer's own `zIndex` option) so
// stacking is robust across zoom-transition animations, which otherwise
// apply their own z-index to the layer's internal wrapper. Note: Leaflet
// strips the "Pane" suffix from the name to build the CSS class, so e.g.
// "mapPane" would collide with Leaflet's own built-in `leaflet-map-pane`
// (the whole map's transform container) hence "osmBasePane" here.
map.createPane('osmBasePane').style.zIndex = '200'
map.createPane('aerialPane').style.zIndex = '300'
map.createPane('buildingsPane').style.zIndex = '400'
map.createPane('drawingsPane').style.zIndex = '500'
// Each TileLayer's own maxZoom defaults to 18 regardless of the map's: it
// must be raised to match our resolutions array, or GridLayer silently
// renders nothing once the map zoom exceeds it. maxNativeZoom caps where
// Leaflet stops fetching new tiles and starts upscaling the deepest one.
const baseZoomBounds = { minZoom: 0, maxZoom: MAX_ZOOM, maxNativeZoom: BASE_LAYERS_MAX_NATIVE_ZOOM }
const buildingsZoomBounds = { minZoom: 0, maxZoom: MAX_ZOOM, maxNativeZoom: BUILDINGS_MAX_NATIVE_ZOOM }
osmLayer = L.tileLayer(EPFL_TILES_URL, {
...baseZoomBounds,
pane: 'osmBasePane',
subdomains: EPFL_TILE_SUBDOMAINS,
layer: 'osm-wmts',
date: '20250317',
floorPath: '',
attribution: '&copy; EPFL &middot; &copy; OpenStreetMap contributors',
} as EpflTileLayerOptions)
if (showMap.value) osmLayer.addTo(map)
aerialLayer = L.tileLayer(AERIAL_TILES_URL, {
...baseZoomBounds,
pane: 'aerialPane',
opacity: aerialOpacity.value,
attribution: '&copy; swisstopo',
})
if (showAerial.value) aerialLayer.addTo(map)
buildingsLayer = L.tileLayer(EPFL_TILES_URL, {
...buildingsZoomBounds,
pane: 'buildingsPane',
subdomains: EPFL_TILE_SUBDOMAINS,
layer: 'batiments',
date: '20231011',
floorPath: `${floor.value}/`,
} as EpflTileLayerOptions).addTo(map)
drawnItems = L.featureGroup().addTo(map)
loadFloorIntoDrawnItems()
if (editable) {
await import('leaflet-draw')
// leaflet-draw 1.0.4's GeometryUtil.readableArea assigns to an
// undeclared `type` variable, which throws under strict-mode ESM
// bundling (it silently leaked a global in classic <script> usage
// instead). Replace it with an equivalent metric-only implementation.
L.GeometryUtil.readableArea = (area: number) => {
if (area >= 1_000_000) return `${(area / 1_000_000).toFixed(2)} km²`
if (area >= 10_000) return `${(area / 10_000).toFixed(2)} ha`
return `${Math.round(area)}`
}
const drawControl = new L.Control.Draw({
edit: { featureGroup: drawnItems },
draw: {
marker: { pane: 'drawingsPane' } as unknown as Leaflet.MarkerOptions,
polyline: { shapeOptions: { pane: 'drawingsPane' } },
polygon: { shapeOptions: { pane: 'drawingsPane' } },
rectangle: { shapeOptions: { pane: 'drawingsPane' } },
circle: { shapeOptions: { pane: 'drawingsPane' } },
circlemarker: false,
},
})
map.addControl(drawControl)
map.on(L.Draw.Event.CREATED, (e) => {
const event = e as Leaflet.DrawEvents.Created
const layer = event.layer as Leaflet.Layer & { feature?: GeoJSON.Feature }
const properties: GeoJSON.GeoJsonProperties = { floor: floor.value }
if (hasSetStyle(layer)) {
const dashArray = drawDashed.value ? DASHED_PATTERN : undefined
layer.setStyle({ color: drawColor.value, dashArray })
properties.color = drawColor.value
properties.dashArray = dashArray ?? null
}
layer.feature = { type: 'Feature', properties, geometry: null as never }
attachStyleClickHandler(layer)
drawnItems?.addLayer(layer)
emitCurrentShapes()
})
map.on(L.Draw.Event.EDITED, emitCurrentShapes)
map.on(L.Draw.Event.DELETED, emitCurrentShapes)
}
})
watch(floor, (value) => {
if (buildingsLayer) {
;(buildingsLayer.options as EpflTileLayerOptions).floorPath = `${value}/`
buildingsLayer.redraw()
}
loadFloorIntoDrawnItems()
})
watch(showMap, (value) => {
if (!map || !osmLayer) return
if (value) osmLayer.addTo(map)
else map.removeLayer(osmLayer)
})
watch(showAerial, (value) => {
if (!map || !aerialLayer) return
if (value) aerialLayer.addTo(map)
else map.removeLayer(aerialLayer)
})
watch(aerialOpacity, (value) => {
aerialLayer?.setOpacity(value)
})
function changeFloor(delta: number) {
floor.value += delta
}
onBeforeUnmount(() => {
map?.remove()
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="icon-sm" @click="changeFloor(-1)">-</Button>
<span class="w-24 text-center text-sm">{{ $t('admin.floor-label', { floor }) }}</span>
<Button type="button" variant="outline" size="icon-sm" @click="changeFloor(1)">+</Button>
<template v-if="editable">
<label class="ml-2 flex items-center gap-1 text-sm">
{{ $t('admin.draw-color') }}
<input v-model="drawColor" type="color" class="h-7 w-9 cursor-pointer rounded border" />
</label>
<select v-model="drawDashed" class="rounded-md border px-2 py-1 text-sm">
<option :value="false">{{ $t('admin.line-solid') }}</option>
<option :value="true">{{ $t('admin.line-dashed') }}</option>
</select>
<Button type="button" variant="outline" size="sm" @click="openAnnotationDialog('text')">
{{ $t('admin.add-text') }}
</Button>
<Button type="button" variant="outline" size="sm" @click="openAnnotationDialog('icon')">
{{ $t('admin.add-icon') }}
</Button>
</template>
<div class="relative ml-auto">
<Button
type="button"
variant="outline"
size="sm"
@click="backgroundPanelOpen = !backgroundPanelOpen"
>
{{ $t('admin.background-layer') }}
</Button>
<div
v-if="backgroundPanelOpen"
class="bg-background absolute right-0 z-[1000] mt-1 w-56 rounded-md border p-3 shadow-md"
>
<label class="flex items-center gap-2 text-sm">
<input v-model="showMap" type="checkbox" />
{{ $t('admin.layer-map') }}
</label>
<label class="mt-2 flex items-center gap-2 text-sm">
<input v-model="showAerial" type="checkbox" />
{{ $t('admin.layer-aerial') }}
</label>
<input
v-if="showAerial"
v-model.number="aerialOpacity"
type="range"
min="0"
max="1"
step="0.01"
class="mt-2 w-full"
:aria-label="t('admin.layer-aerial-opacity')"
/>
</div>
</div>
</div>
<p v-if="editable" class="text-muted-foreground text-xs">{{ $t('admin.annotation-hint') }}</p>
<div ref="mapContainer" class="isolate h-[500px] w-full rounded-md border" />
<Dialog v-model:open="styleDialogOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t('admin.style-title') }}</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-3">
<label class="flex items-center gap-2 text-sm">
{{ $t('admin.draw-color') }}
<input v-model="styleForm.color" type="color" class="h-8 w-10 cursor-pointer rounded border" />
</label>
<select v-model="styleForm.dashed" class="rounded-md border px-2 py-1 text-sm">
<option :value="false">{{ $t('admin.line-solid') }}</option>
<option :value="true">{{ $t('admin.line-dashed') }}</option>
</select>
</div>
<DialogFooter>
<Button type="button" @click="applyStyleToSelected">{{ $t('admin.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="annotationDialogOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>
{{ annotationKind === 'text' ? $t('admin.add-text') : $t('admin.add-icon') }}
</DialogTitle>
</DialogHeader>
<form class="flex flex-col gap-3" @submit.prevent="confirmAnnotation">
<Input
v-if="annotationKind === 'text'"
v-model="annotationForm.text"
:placeholder="$t('admin.text-placeholder')"
required
/>
<template v-else>
<p class="text-muted-foreground text-xs">{{ $t('admin.icon-hint') }}</p>
<Textarea
v-model="annotationForm.svg"
rows="6"
:placeholder="$t('admin.icon-placeholder')"
required
/>
</template>
<DialogFooter>
<Button type="submit">{{ $t('admin.add-to-plan') }}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</template>
<style>
.plan-annotation-marker {
background: transparent;
border: none;
overflow: visible;
}
.plan-annotation-text {
width: max-content;
max-width: 200px;
font-weight: 600;
font-size: 13px;
padding: 1px 4px;
background: rgba(255, 255, 255, 0.85);
border-radius: 4px;
white-space: pre-wrap;
}
.plan-annotation-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
line-height: 1;
}
.plan-annotation-icon svg {
width: 100%;
height: 100%;
}
</style>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot />
</AccordionItem>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from '@lucide/vue'
import { AccordionHeader, AccordionTrigger } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown class="h-4 w-4 shrink-0 transition-transform duration-200" />
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>

View file

@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui'
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)"
>
<slot />
</AlertDialogCancel>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 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 sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogDescription } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogTitle v-bind="delegatedProps" :class="cn('text-lg font-semibold', props.class)">
<slot />
</AlertDialogTitle>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from 'reka-ui'
import { AlertDialogTrigger } from 'reka-ui'
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View file

@ -0,0 +1,9 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<{
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,25 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex gap-1 items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View file

@ -0,0 +1,36 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
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 rounded-md gap-1.5 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 ButtonVariants = VariantProps<typeof buttonVariants>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('rounded-lg border bg-card text-card-foreground shadow-sm', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<h3 :class="cn('text-2xl font-semibold leading-none tracking-tight', props.class)">
<slot />
</h3>
</template>

View file

@ -0,0 +1,6 @@
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui'
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ComboboxRootProps>()
const emits = defineEmits<ComboboxRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ComboboxRoot v-bind="forwarded">
<slot />
</ComboboxRoot>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxAnchorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxAnchor, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxAnchor v-bind="forwarded" :class="cn('w-[200px]', props.class)">
<slot />
</ComboboxAnchor>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { ComboboxEmptyProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxEmpty } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ComboboxEmpty v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</ComboboxEmpty>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ComboboxGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxGroup, ComboboxLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<
ComboboxGroupProps & {
class?: HTMLAttributes['class']
heading?: string
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ComboboxGroup
v-bind="delegatedProps"
:class="
cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
props.class,
)
"
>
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ComboboxInputEmits, ComboboxInputProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxInput, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<
ComboboxInputProps & {
class?: HTMLAttributes['class']
}
>()
const emits = defineEmits<ComboboxInputEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxInput
v-bind="forwarded"
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<slot />
</ComboboxInput>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ComboboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default gap-2 select-none justify-between items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
props.class,
)
"
>
<slot />
</ComboboxItem>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxContent, ComboboxPortal, ComboboxViewport, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper',
align: 'center',
sideOffset: 4,
},
)
const emits = defineEmits<ComboboxContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ComboboxPortal>
<ComboboxContent
v-bind="forwarded"
:class="
cn(
'z-50 w-[200px] rounded-md border bg-popover text-popover-foreground shadow-md outline-none 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
)
"
>
<ComboboxViewport>
<slot />
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ComboboxSeparator v-bind="delegatedProps" :class="cn('-mx-1 h-px bg-border', props.class)">
<slot />
</ComboboxSeparator>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ComboboxTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ComboboxTrigger v-bind="forwarded" :class="cn('', props.class)" tabindex="0">
<slot />
</ComboboxTrigger>
</template>

View file

@ -0,0 +1,11 @@
export { default as Combobox } from './Combobox.vue'
export { default as ComboboxAnchor } from './ComboboxAnchor.vue'
export { default as ComboboxEmpty } from './ComboboxEmpty.vue'
export { default as ComboboxGroup } from './ComboboxGroup.vue'
export { default as ComboboxInput } from './ComboboxInput.vue'
export { default as ComboboxItem } from './ComboboxItem.vue'
export { default as ComboboxList } from './ComboboxList.vue'
export { default as ComboboxSeparator } from './ComboboxSeparator.vue'
export { default as ComboboxTrigger } from './ComboboxTrigger.vue'
export { ComboboxCancel, ComboboxItemIndicator } from 'reka-ui'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot data-slot="dialog" v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import DialogOverlay from './DialogOverlay.vue'
interface DialogContentPropsWithClass extends DialogContentProps {
class?: HTMLAttributes['class']
}
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentPropsWithClass>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="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 top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg duration-200',
props.class,
)
"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogDescription
data-slot="dialog-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="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/80',
props.class,
)
"
v-bind="delegatedProps"
/>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogTitle
data-slot="dialog-title"
:class="cn('text-lg leading-none font-semibold', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View file

@ -0,0 +1,9 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot data-slot="dropdown-menu" v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from '@lucide/vue'
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check class="size-4" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuContent, DropdownMenuPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
props.class,
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from 'reka-ui'
import { DropdownMenuGroup } from 'reka-ui'
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup data-slot="dropdown-menu-group" v-bind="props">
<slot />
</DropdownMenuGroup>
</template>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<
DropdownMenuItemProps & {
class?: HTMLAttributes['class']
inset?: boolean
variant?: 'default' | 'destructive'
}
>(),
{
variant: 'default',
},
)
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</DropdownMenuItem>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<
DropdownMenuLabelProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
import { DropdownMenuRadioGroup, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup data-slot="dropdown-menu-radio-group" v-bind="forwarded">
<slot />
</DropdownMenuRadioGroup>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Circle } from '@lucide/vue'
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Circle class="size-2 fill-current" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui'
import { DropdownMenuSub, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot />
</DropdownMenuSub>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuSubContent, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
props.class,
)
"
>
<slot />
</DropdownMenuSubContent>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from '@lucide/vue'
import { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<
DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)
"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger data-slot="dropdown-menu-trigger" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>

View file

@ -0,0 +1,16 @@
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { DropdownMenuPortal } from 'reka-ui'

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>

View file

@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator-root"
v-bind="delegatedProps"
:class="
cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
/>
</template>

View file

@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot data-slot="sheet" v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose data-slot="sheet-close" v-bind="props">
<slot />
</DialogClose>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import SheetOverlay from './SheetOverlay.vue'
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes['class']
side?: 'top' | 'right' | 'bottom' | 'left'
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: 'right',
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'side')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class,
)
"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogDescription
data-slot="sheet-description"
:class="cn('text-muted-foreground text-sm', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogDescription>
</template>

Some files were not shown because too many files have changed in this diff Show more