first commit
This commit is contained in:
commit
52363d59ac
225 changed files with 21984 additions and 0 deletions
2
.env
Normal file
2
.env
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
config.yml
|
||||
config.yaml
|
||||
2827
Cargo.lock
generated
Normal file
2827
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal 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
156
README.md
Normal 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
20
build.rs
Normal 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
25
config.example.yml
Normal 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
|
||||
12
db/migrations/20260730000000_create_users.sql
Normal file
12
db/migrations/20260730000000_create_users.sql
Normal 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;
|
||||
22
db/migrations/20260730000001_create_events.sql
Normal file
22
db/migrations/20260730000001_create_events.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
||||
8
db/migrations/20260730020605_add_plan_to_events.sql
Normal file
8
db/migrations/20260730020605_add_plan_to_events.sql
Normal 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
198
db/schema.sql
Normal 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
1
dev-db/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
db
|
||||
25
dev-db/docker-compose.yml
Normal file
25
dev-db/docker-compose.yml
Normal 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
52
dev-db/login-servers.php
Normal 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
8
frontend/.editorconfig
Normal 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
1
frontend/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
||||
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal 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
|
||||
6
frontend/.prettierrc.json
Normal file
6
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal 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
1
frontend/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
32
frontend/eslint.config.ts
Normal file
32
frontend/eslint.config.ts
Normal 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
18
frontend/index.html
Normal 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
6697
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
64
frontend/package.json
Normal file
64
frontend/package.json
Normal 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
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
16
frontend/src/App.vue
Normal 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>
|
||||
11
frontend/src/components/AppContent.vue
Normal file
11
frontend/src/components/AppContent.vue
Normal 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>
|
||||
129
frontend/src/components/AppSidebar.vue
Normal file
129
frontend/src/components/AppSidebar.vue
Normal 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>
|
||||
62
frontend/src/components/EventFieldsForm.vue
Normal file
62
frontend/src/components/EventFieldsForm.vue
Normal 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>
|
||||
613
frontend/src/components/PlanMap.vue
Normal file
613
frontend/src/components/PlanMap.vue
Normal 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: '© EPFL · © OpenStreetMap contributors',
|
||||
} as EpflTileLayerOptions)
|
||||
if (showMap.value) osmLayer.addTo(map)
|
||||
|
||||
aerialLayer = L.tileLayer(AERIAL_TILES_URL, {
|
||||
...baseZoomBounds,
|
||||
pane: 'aerialPane',
|
||||
opacity: aerialOpacity.value,
|
||||
attribution: '© 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)} m²`
|
||||
}
|
||||
|
||||
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>
|
||||
15
frontend/src/components/ui/accordion/Accordion.vue
Normal file
15
frontend/src/components/ui/accordion/Accordion.vue
Normal 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>
|
||||
22
frontend/src/components/ui/accordion/AccordionContent.vue
Normal file
22
frontend/src/components/ui/accordion/AccordionContent.vue
Normal 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>
|
||||
19
frontend/src/components/ui/accordion/AccordionItem.vue
Normal file
19
frontend/src/components/ui/accordion/AccordionItem.vue
Normal 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>
|
||||
31
frontend/src/components/ui/accordion/AccordionTrigger.vue
Normal file
31
frontend/src/components/ui/accordion/AccordionTrigger.vue
Normal 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>
|
||||
4
frontend/src/components/ui/accordion/index.ts
Normal file
4
frontend/src/components/ui/accordion/index.ts
Normal 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'
|
||||
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
17
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
17
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
9
frontend/src/components/ui/alert-dialog/index.ts
Normal 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'
|
||||
17
frontend/src/components/ui/badge/Badge.vue
Normal file
17
frontend/src/components/ui/badge/Badge.vue
Normal 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>
|
||||
25
frontend/src/components/ui/badge/index.ts
Normal file
25
frontend/src/components/ui/badge/index.ts
Normal 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>
|
||||
29
frontend/src/components/ui/button/Button.vue
Normal file
29
frontend/src/components/ui/button/Button.vue
Normal 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>
|
||||
36
frontend/src/components/ui/button/index.ts
Normal file
36
frontend/src/components/ui/button/index.ts
Normal 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>
|
||||
14
frontend/src/components/ui/card/Card.vue
Normal file
14
frontend/src/components/ui/card/Card.vue
Normal 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>
|
||||
14
frontend/src/components/ui/card/CardContent.vue
Normal file
14
frontend/src/components/ui/card/CardContent.vue
Normal 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>
|
||||
14
frontend/src/components/ui/card/CardDescription.vue
Normal file
14
frontend/src/components/ui/card/CardDescription.vue
Normal 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>
|
||||
14
frontend/src/components/ui/card/CardFooter.vue
Normal file
14
frontend/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||
14
frontend/src/components/ui/card/CardHeader.vue
Normal file
14
frontend/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||
14
frontend/src/components/ui/card/CardTitle.vue
Normal file
14
frontend/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||
6
frontend/src/components/ui/card/index.ts
Normal file
6
frontend/src/components/ui/card/index.ts
Normal 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'
|
||||
15
frontend/src/components/ui/combobox/Combobox.vue
Normal file
15
frontend/src/components/ui/combobox/Combobox.vue
Normal 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>
|
||||
19
frontend/src/components/ui/combobox/ComboboxAnchor.vue
Normal file
19
frontend/src/components/ui/combobox/ComboboxAnchor.vue
Normal 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>
|
||||
17
frontend/src/components/ui/combobox/ComboboxEmpty.vue
Normal file
17
frontend/src/components/ui/combobox/ComboboxEmpty.vue
Normal 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>
|
||||
33
frontend/src/components/ui/combobox/ComboboxGroup.vue
Normal file
33
frontend/src/components/ui/combobox/ComboboxGroup.vue
Normal 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>
|
||||
33
frontend/src/components/ui/combobox/ComboboxInput.vue
Normal file
33
frontend/src/components/ui/combobox/ComboboxInput.vue
Normal 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>
|
||||
28
frontend/src/components/ui/combobox/ComboboxItem.vue
Normal file
28
frontend/src/components/ui/combobox/ComboboxItem.vue
Normal 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>
|
||||
39
frontend/src/components/ui/combobox/ComboboxList.vue
Normal file
39
frontend/src/components/ui/combobox/ComboboxList.vue
Normal 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>
|
||||
17
frontend/src/components/ui/combobox/ComboboxSeparator.vue
Normal file
17
frontend/src/components/ui/combobox/ComboboxSeparator.vue
Normal 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>
|
||||
19
frontend/src/components/ui/combobox/ComboboxTrigger.vue
Normal file
19
frontend/src/components/ui/combobox/ComboboxTrigger.vue
Normal 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>
|
||||
11
frontend/src/components/ui/combobox/index.ts
Normal file
11
frontend/src/components/ui/combobox/index.ts
Normal 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'
|
||||
15
frontend/src/components/ui/dialog/Dialog.vue
Normal file
15
frontend/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
12
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
12
frontend/src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
49
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
49
frontend/src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
21
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
21
frontend/src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
15
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
15
frontend/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
15
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
15
frontend/src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
24
frontend/src/components/ui/dialog/DialogOverlay.vue
Normal file
24
frontend/src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||
21
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
21
frontend/src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
12
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
12
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
9
frontend/src/components/ui/dialog/index.ts
Normal file
9
frontend/src/components/ui/dialog/index.ts
Normal 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'
|
||||
15
frontend/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
15
frontend/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
15
frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
15
frontend/src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
16
frontend/src/components/ui/dropdown-menu/index.ts
Normal file
16
frontend/src/components/ui/dropdown-menu/index.ts
Normal 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'
|
||||
35
frontend/src/components/ui/input/Input.vue
Normal file
35
frontend/src/components/ui/input/Input.vue
Normal 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>
|
||||
1
frontend/src/components/ui/input/index.ts
Normal file
1
frontend/src/components/ui/input/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Input } from './Input.vue'
|
||||
27
frontend/src/components/ui/separator/Separator.vue
Normal file
27
frontend/src/components/ui/separator/Separator.vue
Normal 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>
|
||||
1
frontend/src/components/ui/separator/index.ts
Normal file
1
frontend/src/components/ui/separator/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Separator } from './Separator.vue'
|
||||
15
frontend/src/components/ui/sheet/Sheet.vue
Normal file
15
frontend/src/components/ui/sheet/Sheet.vue
Normal 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>
|
||||
12
frontend/src/components/ui/sheet/SheetClose.vue
Normal file
12
frontend/src/components/ui/sheet/SheetClose.vue
Normal 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>
|
||||
60
frontend/src/components/ui/sheet/SheetContent.vue
Normal file
60
frontend/src/components/ui/sheet/SheetContent.vue
Normal 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>
|
||||
21
frontend/src/components/ui/sheet/SheetDescription.vue
Normal file
21
frontend/src/components/ui/sheet/SheetDescription.vue
Normal 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
Loading…
Add table
Reference in a new issue