Compare commits
6 commits
models-for
...
main
Author | SHA1 | Date | |
---|---|---|---|
56716cf1e5 | |||
6ea4409d68 | |||
0611aac45f | |||
137dfa747d | |||
65ad20d197 | |||
e0653e4bdd |
69 changed files with 3773 additions and 5651 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,3 +3,4 @@ node_modules/
|
|||
*.db
|
||||
*.xml
|
||||
.env
|
||||
static/
|
||||
|
|
2005
Cargo.lock
generated
2005
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
20
Cargo.toml
20
Cargo.toml
|
@ -12,20 +12,28 @@ argon2 = { version = "0.5.3", features = ["rand", "std"] }
|
|||
async-trait = "0.1.78"
|
||||
axum = "0.7.4"
|
||||
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
|
||||
axum-login = "0.14.0"
|
||||
axum-login = "0.15"
|
||||
bincode = "1.3.3"
|
||||
chrono = "0.4.38"
|
||||
clap = { version = "4.5.3", features = ["derive", "env"] }
|
||||
diesel = { version = "2.2.0", features = ["extras", "returning_clauses_for_sqlite_3_35", "sqlite"] }
|
||||
diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
|
||||
dotenvy = "0.15.7"
|
||||
env_logger = "0.11.3"
|
||||
minijinja = { version = "1.0.14", features = ["loader"] }
|
||||
free-icons = "0.7.0"
|
||||
minijinja = { version = "1.0.14", features = ["loader", "json", "builtins"] }
|
||||
minijinja-autoreload = "1.0.14"
|
||||
pulldown-cmark = "0.11.0"
|
||||
rand = "0.8.5"
|
||||
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
|
||||
redb = "2.1.0"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
sled = "=1.0.0-alpha.121"
|
||||
thiserror = "1.0.58"
|
||||
tokio = { version = "1.36.0", features = ["rt", "full"] }
|
||||
tower-http = { version = "0.5.2", features = ["fs", "trace"] }
|
||||
tower-sessions = "0.11.1"
|
||||
tower-sessions-moka-store = "0.11.0"
|
||||
tower-sessions-sqlx-store = { version = "0.11.0", features = ["sqlite"] }
|
||||
tower-sessions = "0.12"
|
||||
tower-sessions-moka-store = "0.12"
|
||||
tower-sessions-sqlx-store = { version = "0.12", features = ["sqlite"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "v7", "serde"] }
|
||||
|
|
19
Makefile
19
Makefile
|
@ -1,15 +1,24 @@
|
|||
|
||||
check:
|
||||
cargo test
|
||||
cargo fmt --check
|
||||
cargo clippy
|
||||
cargo build --release
|
||||
|
||||
run:
|
||||
SECURE_SESSIONS=false RUST_LOG=debug cargo run -- --reload-templates
|
||||
|
||||
run-watch:
|
||||
SECURE_SESSIONS=false RUST_LOG=debug cargo watch --why -x 'run -- --reload-templates'
|
||||
|
||||
run-release:
|
||||
SECURE_SESSIONS=false RUST_LOG=info cargo run --release
|
||||
|
||||
css-watch:
|
||||
npx tailwindcss -i ./src/main.css -o ./static/main.css --watch
|
||||
npm run css-watch
|
||||
|
||||
migrate:
|
||||
sea-orm-cli migrate
|
||||
typescript:
|
||||
npm run build
|
||||
|
||||
entities:
|
||||
sea-orm-cli generate entity -o src/entity --with-serde both
|
||||
typescript-watch:
|
||||
npm run build-watch
|
||||
|
|
11
README.md
11
README.md
|
@ -34,10 +34,15 @@ We use nightly, and installation and management using [rustup][rustup] is
|
|||
recommended.
|
||||
|
||||
|
||||
### SeaORM
|
||||
### DB (Diesel)
|
||||
|
||||
We use SeaORM for database interaction. You'll want the CLI, which you can
|
||||
install with `cargo install sea-orm-cli`.
|
||||
We use [Diesel](https://diesel.rs/) for database interaction. You'll want the
|
||||
CLI, which you can install with the following command. This will install it for
|
||||
your user on your system, including support for SQLite.
|
||||
|
||||
```bash
|
||||
cargo install diesel_cli --no-default-features -F sqlite-bundled
|
||||
```
|
||||
|
||||
|
||||
### Tailwind
|
||||
|
|
47
_docs/decisions/0004-uuids-for-primary-keys.md
Normal file
47
_docs/decisions/0004-uuids-for-primary-keys.md
Normal file
|
@ -0,0 +1,47 @@
|
|||
# 4. UUIDs for primary keys
|
||||
|
||||
Date: 2024-05-31
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
|
||||
|
||||
## Context
|
||||
|
||||
We need primary keys in our database.
|
||||
|
||||
I've used integers and UUIDs for this in different contexts. Ultimately, we have
|
||||
to decide on which one to use.
|
||||
|
||||
|
||||
## Decision
|
||||
|
||||
We're going to use UUIDs for our primary keys.
|
||||
|
||||
The primary motivation here is that it will give us the ability to generate IDs
|
||||
before inserting records, and it lets us expose the IDs more easily. Instead of
|
||||
either leaking information (count of users, etc.) or having a secondary mapping
|
||||
for URLs, we can easily use the ID in a URL to map to a record for lookup.
|
||||
|
||||
|
||||
## Consequences
|
||||
|
||||
There are some drawbacks:
|
||||
|
||||
- We lose some type safety, becasue SQLite only supports text/blob types and
|
||||
it's been a blocker trying to implement custom sql types in Diesel, so this is
|
||||
going to be done by converting to strings and operating on these IDs as
|
||||
strings.
|
||||
- They take up more space
|
||||
|
||||
However, we get these benefits:
|
||||
|
||||
- We can expose primary keys without leaking information. This makes it so we
|
||||
do not need secondary IDs (and associated indexes) for looking up specific
|
||||
records and putting them in URLs, where if we used integers we'd need that or
|
||||
we would have to accept exposing the number of records we have.
|
||||
- IDs are unique across tables, so they should give us the ability to find a
|
||||
particular row even if we don't know the table. This also means we could link
|
||||
events, like edit events, to any table via UUID.
|
9
diesel.toml
Normal file
9
diesel.toml
Normal file
|
@ -0,0 +1,9 @@
|
|||
# For documentation on how to configure this file,
|
||||
# see https://diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
|
||||
|
||||
[migrations_directory]
|
||||
dir = "./migrations"
|
40
frontend/editor.ts
Normal file
40
frontend/editor.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import {basicSetup} from "codemirror"
|
||||
import {EditorView, keymap} from "@codemirror/view"
|
||||
import {indentWithTab} from "@codemirror/commands"
|
||||
import {markdown} from "@codemirror/lang-markdown"
|
||||
|
||||
export function makeEditor(divSelector, value) {
|
||||
let div = document.querySelector(divSelector);
|
||||
|
||||
let documentTheme = EditorView.theme({
|
||||
"&": {
|
||||
"background-color": "white",
|
||||
},
|
||||
".cm-editor": {
|
||||
"height": "100%",
|
||||
},
|
||||
".cm-scroller": {overflow: "auto"}
|
||||
}, {})
|
||||
|
||||
// add a hidden textarea inside the div for form submission
|
||||
let textarea = document.createElement("textarea");
|
||||
textarea.setAttribute("name", "content");
|
||||
textarea.style.display = "none";
|
||||
div.appendChild(textarea);
|
||||
|
||||
let extensions = [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
markdown(),
|
||||
documentTheme,
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
|
||||
let view = new EditorView({parent: div, doc: value, extensions})
|
||||
|
||||
textarea.form.addEventListener("submit", () => {
|
||||
textarea.value = view.state.doc.toString()
|
||||
})
|
||||
|
||||
return view
|
||||
}
|
17
frontend/main.css
Normal file
17
frontend/main.css
Normal file
|
@ -0,0 +1,17 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.prose li {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
2
frontend/main.ts
Normal file
2
frontend/main.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import { makeEditor } from './editor';
|
||||
window.makeEditor = makeEditor;
|
2351
migration/Cargo.lock
generated
2351
migration/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,19 +0,0 @@
|
|||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "0.12.0"
|
||||
features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlx-sqlite",
|
||||
]
|
|
@ -1,41 +0,0 @@
|
|||
# Running Migrator CLI
|
||||
|
||||
- Generate a new migration file
|
||||
```sh
|
||||
cargo run -- generate MIGRATION_NAME
|
||||
```
|
||||
- Apply all pending migrations
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
```sh
|
||||
cargo run -- up
|
||||
```
|
||||
- Apply first 10 pending migrations
|
||||
```sh
|
||||
cargo run -- up -n 10
|
||||
```
|
||||
- Rollback last applied migrations
|
||||
```sh
|
||||
cargo run -- down
|
||||
```
|
||||
- Rollback last 10 applied migrations
|
||||
```sh
|
||||
cargo run -- down -n 10
|
||||
```
|
||||
- Drop all tables from the database, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- fresh
|
||||
```
|
||||
- Rollback all applied migrations, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- refresh
|
||||
```
|
||||
- Rollback all applied migrations
|
||||
```sh
|
||||
cargo run -- reset
|
||||
```
|
||||
- Check the status of all migrations
|
||||
```sh
|
||||
cargo run -- status
|
||||
```
|
|
@ -1,14 +0,0 @@
|
|||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20240316_155147_create_users_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20240316_155147_create_users_table::Migration),
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(User::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(User::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(User::FullName).string_len(100).not_null())
|
||||
.col(
|
||||
ColumnDef::new(User::Email)
|
||||
.string_len(100)
|
||||
.unique()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(User::Username)
|
||||
.string_len(32)
|
||||
.unique()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(User::PasswordHash).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(User::Created)
|
||||
.date_time()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(User::Updated)
|
||||
.date_time()
|
||||
.default(Expr::current_timestamp())
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum User {
|
||||
Table,
|
||||
Id,
|
||||
FullName,
|
||||
Email,
|
||||
Username,
|
||||
PasswordHash,
|
||||
Created,
|
||||
Updated,
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
}
|
1
migrations/2024-05-31-175324_users/down.sql
Normal file
1
migrations/2024-05-31-175324_users/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS users;
|
7
migrations/2024-05-31-175324_users/up.sql
Normal file
7
migrations/2024-05-31-175324_users/up.sql
Normal file
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE CHECK (LENGTH(username) <= 32),
|
||||
password_hash TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE CHECK (LENGTH(email) <= 100),
|
||||
name TEXT NOT NULL CHECK (LENGTH(name) <= 100)
|
||||
);
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS projects;
|
||||
DROP TABLE IF EXISTS documents;
|
18
migrations/2024-05-31-203133_projects_and_documents/up.sql
Normal file
18
migrations/2024-05-31-203133_projects_and_documents/up.sql
Normal file
|
@ -0,0 +1,18 @@
|
|||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||
creator_id UUID_TEXT NOT NULL,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
|
||||
key TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id UUID_TEXT PRIMARY KEY NOT NULL UNIQUE,
|
||||
creator_id UUID_TEXT NOT NULL,
|
||||
project_id UUID_TEXT NOT NULL,
|
||||
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
);
|
1
migrations/2024-05-31-204416_permissions/down.sql
Normal file
1
migrations/2024-05-31-204416_permissions/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS project_memberships;
|
8
migrations/2024-05-31-204416_permissions/up.sql
Normal file
8
migrations/2024-05-31-204416_permissions/up.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
CREATE TABLE IF NOT EXISTS project_memberships(
|
||||
id INTEGER PRIMARY KEY NOT NULL UNIQUE,
|
||||
|
||||
user_id UUID_TEXT NOT NULL,
|
||||
project_id UUID_TEXT NOT NULL,
|
||||
role TEXT NOT NULL
|
||||
);
|
810
package-lock.json
generated
810
package-lock.json
generated
|
@ -4,8 +4,18 @@
|
|||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.2.5",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.1"
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"daisyui": "^4.10.5",
|
||||
"esbuild": "0.21.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
@ -20,6 +30,506 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz",
|
||||
"integrity": "sha512-MjfDrHy0gHKlPWsvSsikhO1+BOh+eBHNgfH1OXs1+DAf30IonQldgMM3kxLDTG9ktE7kDLaA1j/l7KMPA4KNfw==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.5.0.tgz",
|
||||
"integrity": "sha512-rK+sj4fCAN/QfcY9BEzYMgp4wwL/q5aj/VfNSoH1RWPF9XS/dUwBkvlL3hpWgEjOqlpdN1uLC9UkjJ4tmyjJYg==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz",
|
||||
"integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
|
||||
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
|
||||
"integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-markdown": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz",
|
||||
"integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.7.1",
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/markdown": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz",
|
||||
"integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.1.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.0.tgz",
|
||||
"integrity": "sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz",
|
||||
"integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
|
||||
"integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A=="
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.26.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.26.3.tgz",
|
||||
"integrity": "sha512-gmqxkPALZjkgSxIeeweY/wGQXBfwTUaLs8h7OKtSwfbj9Ct3L11lD+u1sS7XHppxFQoMDiMDp07P9f3I2jWOHw==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.1.tgz",
|
||||
"integrity": "sha512-O7yppwipkXvnEPjzkSXJRk2g4bS8sUx9p9oXHq9MU/U7lxUzZVsnFZMDTmeeX9bfQxrFcvOacl/ENgOh0WP9pA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.1.tgz",
|
||||
"integrity": "sha512-hh3jKWikdnTtHCglDAeVO3Oyh8MaH8xZUaWMiCCvJ9/c3NtPqZq+CACOlGTxhddypXhl+8B45SeceYBfB/e8Ow==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.1.tgz",
|
||||
"integrity": "sha512-jXhccq6es+onw7x8MxoFnm820mz7sGa9J14kLADclmiEUH4fyj+FjR6t0M93RgtlI/awHWhtF0Wgfhqgf9gDZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-NPObtlBh4jQHE01gJeucqEhdoD/4ya2owSIS8lZYS58aR0x7oZo9lB2lVFxgTANSa5MGCBeoQtr+yA9oKCGPvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.1.tgz",
|
||||
"integrity": "sha512-BLT7TDzqsVlQRmJfO/FirzKlzmDpBWwmCUlyggfzUwg1cAxVxeA4O6b1XkMInlxISdfPAOunV9zXjvh5x99Heg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-D3h3wBQmeS/vp93O4B+SWsXB8HvRDwMyhTNhBd8yMbh5wN/2pPWRW5o/hM3EKgk9bdKd9594lMGoTCTiglQGRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.1.tgz",
|
||||
"integrity": "sha512-/uVdqqpNKXIxT6TyS/oSK4XE4xWOqp6fh4B5tgAwozkyWdylcX+W4YF2v6SKsL4wCQ5h1bnaSNjWPXG/2hp8AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-paAkKN1n1jJitw+dAoR27TdCzxRl1FOEITx3h201R6NoXUojpMzgMLdkXVgCvaCSCqwYkeGLoe9UVNRDKSvQgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.1.tgz",
|
||||
"integrity": "sha512-tRHnxWJnvNnDpNVnsyDhr1DIQZUfCXlHSCDohbXFqmg9W4kKR7g8LmA3kzcwbuxbRMKeit8ladnCabU5f2traA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.1.tgz",
|
||||
"integrity": "sha512-G65d08YoH00TL7Xg4LaL3gLV21bpoAhQ+r31NUu013YB7KK0fyXIt05VbsJtpqh/6wWxoLJZOvQHYnodRrnbUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.1.tgz",
|
||||
"integrity": "sha512-tt/54LqNNAqCz++QhxoqB9+XqdsaZOtFD/srEhHYwBd3ZUOepmR1Eeot8bS+Q7BiEvy9vvKbtpHf+r6q8hF5UA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.1.tgz",
|
||||
"integrity": "sha512-MhNalK6r0nZD0q8VzUBPwheHzXPr9wronqmZrewLfP7ui9Fv1tdPmg6e7A8lmg0ziQCziSDHxh3cyRt4YMhGnQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.1.tgz",
|
||||
"integrity": "sha512-YCKVY7Zen5rwZV+nZczOhFmHaeIxR4Zn3jcmNH53LbgF6IKRwmrMywqDrg4SiSNApEefkAbPSIzN39FC8VsxPg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.1.tgz",
|
||||
"integrity": "sha512-bw7bcQ+270IOzDV4mcsKAnDtAFqKO0jVv3IgRSd8iM0ac3L8amvCrujRVt1ajBTJcpDaFhIX+lCNRKteoDSLig==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.1.tgz",
|
||||
"integrity": "sha512-ARmDRNkcOGOm1AqUBSwRVDfDeD9hGYRfkudP2QdoonBz1ucWVnfBPfy7H4JPI14eYtZruRSczJxyu7SRYDVOcg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.1.tgz",
|
||||
"integrity": "sha512-o73TcUNMuoTZlhwFdsgr8SfQtmMV58sbgq6gQq9G1xUiYnHMTmJbwq65RzMx89l0iya69lR4bxBgtWiiOyDQZA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-da4/1mBJwwgJkbj4fMH7SOXq2zapgTo0LKXX1VUZ0Dxr+e8N0WbS80nSZ5+zf3lvpf8qxrkZdqkOqFfm57gXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-CPWs0HTFe5woTJN5eKPvgraUoRHrCtzlYIAv9wBC+FAyagBSaf+UdZrjwYyTGnwPGkThV4OCI7XibZOnPvONVw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-xxhTm5QtzNLc24R0hEkcH+zCx/o49AsdFZ0Cy5zSd/5tOj4X2g3/2AJB625NoadUuc4A8B3TenLJoYdWYOYCew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-CWibXszpWys1pYmbr9UiKAkX6x+Sxw8HWtw1dRESK1dLW5fFJ6rMDVw0o8MbadusvVQx1a8xuOxnHXT941Hp1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.1.tgz",
|
||||
"integrity": "sha512-jb5B4k+xkytGbGUS4T+Z89cQJ9DJ4lozGRSV+hhfmCPpfJ3880O31Q1srPCimm+V6UCbnigqD10EgDNgjvjerQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.1.tgz",
|
||||
"integrity": "sha512-PgyFvjJhXqHn1uxPhyN1wZ6dIomKjiLUQh1LjFvjiV1JmnkZ/oMPrfeEAZg5R/1ftz4LZWZr02kefNIQ5SKREQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.1.tgz",
|
||||
"integrity": "sha512-W9NttRZQR5ehAiqHGDnvfDaGmQOm6Fi4vSlce8mjM75x//XKuVAByohlEX6N17yZnVXxQFuh4fDRunP8ca6bfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
@ -85,6 +595,66 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
|
||||
"integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ=="
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz",
|
||||
"integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz",
|
||||
"integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
|
||||
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.16.tgz",
|
||||
"integrity": "sha512-84UXR3N7s11MPQHWgMnjb9571fr19MmXnr5zTv2XX0gHXXUvW3uPJ8GCjKrfTXmSdfktjRK0ayKklw+A13rk4g==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz",
|
||||
"integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/markdown": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.0.tgz",
|
||||
"integrity": "sha512-ErbEQ15eowmJUyT095e9NJc3BI9yZ894fjSDtHftD0InkfUBGgnKSU6dvan9jqsZuNHg2+ag/1oyDRxNsENupQ==",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
@ -130,6 +700,46 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/forms": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz",
|
||||
"integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mini-svg-data-uri": "^1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz",
|
||||
"integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lodash.castarray": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
||||
|
@ -263,6 +873,28 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
|
||||
"integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
@ -290,6 +922,11 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
|
@ -304,6 +941,16 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-selector-tokenizer": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz",
|
||||
"integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"fastparse": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
@ -316,6 +963,34 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/culori": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz",
|
||||
"integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "4.10.5",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.10.5.tgz",
|
||||
"integrity": "sha512-eOFUo5yEg0WV+3VK2C/+/XN1WH/OhFV4HzrMG5etAzcnB2hPg3aoR7gF1ZUpIv+b5MglLuAVMgub0rv660EgZg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"css-selector-tokenizer": "^0.8",
|
||||
"culori": "^3",
|
||||
"picocolors": "^1",
|
||||
"postcss-js": "^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/daisyui"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
|
@ -340,6 +1015,44 @@
|
|||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.1.tgz",
|
||||
"integrity": "sha512-GPqx+FX7mdqulCeQ4TsGZQ3djBJkx5k7zBGtqt9ycVlWNg8llJ4RO9n2vciu8BN2zAEs6lPbPl0asZsAh7oWzg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.1",
|
||||
"@esbuild/android-arm": "0.21.1",
|
||||
"@esbuild/android-arm64": "0.21.1",
|
||||
"@esbuild/android-x64": "0.21.1",
|
||||
"@esbuild/darwin-arm64": "0.21.1",
|
||||
"@esbuild/darwin-x64": "0.21.1",
|
||||
"@esbuild/freebsd-arm64": "0.21.1",
|
||||
"@esbuild/freebsd-x64": "0.21.1",
|
||||
"@esbuild/linux-arm": "0.21.1",
|
||||
"@esbuild/linux-arm64": "0.21.1",
|
||||
"@esbuild/linux-ia32": "0.21.1",
|
||||
"@esbuild/linux-loong64": "0.21.1",
|
||||
"@esbuild/linux-mips64el": "0.21.1",
|
||||
"@esbuild/linux-ppc64": "0.21.1",
|
||||
"@esbuild/linux-riscv64": "0.21.1",
|
||||
"@esbuild/linux-s390x": "0.21.1",
|
||||
"@esbuild/linux-x64": "0.21.1",
|
||||
"@esbuild/netbsd-x64": "0.21.1",
|
||||
"@esbuild/openbsd-x64": "0.21.1",
|
||||
"@esbuild/sunos-x64": "0.21.1",
|
||||
"@esbuild/win32-arm64": "0.21.1",
|
||||
"@esbuild/win32-ia32": "0.21.1",
|
||||
"@esbuild/win32-x64": "0.21.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||
|
@ -368,6 +1081,12 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fastparse": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
||||
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
||||
|
@ -429,16 +1148,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.3.10",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
|
||||
"integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
|
||||
"version": "10.3.12",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz",
|
||||
"integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^2.3.5",
|
||||
"jackspeak": "^2.3.6",
|
||||
"minimatch": "^9.0.1",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
|
||||
"path-scurry": "^1.10.1"
|
||||
"minipass": "^7.0.4",
|
||||
"path-scurry": "^1.10.2"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
|
@ -585,10 +1304,28 @@
|
|||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
|
||||
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
|
||||
"integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==",
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",
|
||||
"integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "14 || >=16.14"
|
||||
|
@ -616,10 +1353,19 @@
|
|||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-svg-data-uri": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
|
||||
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
|
||||
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
|
@ -632,9 +1378,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
|
||||
"integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.0.tgz",
|
||||
"integrity": "sha512-oGZRv2OT1lO2UF1zUcwdTb3wqUwI0kBGTgt/T7OdSj6M6N5m3o5uPf0AIW6lVxGGoiWUR7e2AwTE+xiwK8WQig==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
|
@ -712,12 +1458,12 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
|
||||
"integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz",
|
||||
"integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^9.1.1 || ^10.0.0",
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
@ -1141,6 +1887,11 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
|
||||
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
|
@ -1176,9 +1927,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
|
||||
"integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
|
||||
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
|
@ -1189,7 +1940,7 @@
|
|||
"fast-glob": "^3.3.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"jiti": "^1.19.1",
|
||||
"jiti": "^1.21.0",
|
||||
"lilconfig": "^2.1.0",
|
||||
"micromatch": "^4.0.5",
|
||||
"normalize-path": "^3.0.0",
|
||||
|
@ -1257,6 +2008,11 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
@ -1364,9 +2120,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz",
|
||||
"integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==",
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
|
||||
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
|
18
package.json
18
package.json
|
@ -1,5 +1,21 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.1"
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"daisyui": "^4.10.5",
|
||||
"esbuild": "0.21.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --color=false",
|
||||
"build-watch": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --watch --color=false",
|
||||
"css-watch": "tailwindcss -i ./frontend/main.css -o ./static/style.css --watch",
|
||||
"css": "tailwindcss -i ./frontend/main.css -o ./static/style.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.2.5",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
}
|
||||
}
|
||||
|
|
5
rustfmt.toml
Normal file
5
rustfmt.toml
Normal file
|
@ -0,0 +1,5 @@
|
|||
imports_granularity = "Module"
|
||||
group_imports = "StdExternalCrate"
|
||||
wrap_comments = true
|
||||
use_small_heuristics = "Max"
|
||||
edition = "2021"
|
|
@ -1,24 +1,17 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use pique::{
|
||||
db::{NewUser, UserQuery},
|
||||
prelude::*,
|
||||
};
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{distributions::DistString, thread_rng};
|
||||
use sea_orm::Database;
|
||||
use pique::db::establish_connection;
|
||||
use pique::models::users::{self, NewUser};
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use rand::thread_rng;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() -> Result<()> {
|
||||
dotenvy::dotenv()?;
|
||||
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||
|
||||
match AdminCli::parse().command {
|
||||
AdminCommand::CreateUser {
|
||||
full_name,
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
} => {
|
||||
AdminCommand::CreateUser { name, email, username, password } => {
|
||||
let password = match password {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
|
@ -29,15 +22,9 @@ pub async fn main() -> Result<()> {
|
|||
password
|
||||
}
|
||||
};
|
||||
handle_create_user(NewUser {
|
||||
full_name,
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
.await?
|
||||
handle_create_user(&db_url, NewUser::new(name, username, email, password)).await?
|
||||
}
|
||||
AdminCommand::ListUsers => handle_list_users().await?,
|
||||
AdminCommand::ListUsers => handle_list_users(&db_url).await?,
|
||||
};
|
||||
|
||||
Ok(())
|
||||
|
@ -51,40 +38,29 @@ struct AdminCli {
|
|||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum AdminCommand {
|
||||
CreateUser {
|
||||
full_name: String,
|
||||
email: String,
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
},
|
||||
CreateUser { name: String, email: String, username: String, password: Option<String> },
|
||||
|
||||
ListUsers,
|
||||
}
|
||||
|
||||
async fn handle_create_user(new_user: NewUser) -> Result<()> {
|
||||
let db = connect_to_db().await?;
|
||||
async fn handle_create_user(db_url: &str, new_user: NewUser) -> Result<()> {
|
||||
let mut db = establish_connection(db_url);
|
||||
|
||||
let user = UserQuery(&db).insert(new_user).await?;
|
||||
println!("User created successfully with id = {}", user.id.unwrap());
|
||||
let user = users::q::create(&mut db, new_user)?;
|
||||
println!("User created successfully with id = {}", user.id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_list_users() -> Result<()> {
|
||||
let db = connect_to_db().await?;
|
||||
async fn handle_list_users(db_url: &str) -> Result<()> {
|
||||
let mut db = establish_connection(db_url);
|
||||
|
||||
let users = UserQuery(&db).all().await?;
|
||||
let users = users::q::all(&mut db)?;
|
||||
|
||||
println!("Found {} users.", users.len());
|
||||
for user in users {
|
||||
println!(" > {}: {} ({})", user.id, user.username, user.full_name);
|
||||
println!(" > {}: {} ({})", user.id, user.username, user.name);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_to_db() -> Result<DatabaseConnection> {
|
||||
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||
let db = Database::connect(db_url).await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
use anyhow::Result;
|
||||
|
||||
use pique::server;
|
||||
|
||||
#[tokio::main]
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use minijinja_autoreload::AutoReloader;
|
||||
|
||||
use crate::{handler::internal_server_error, prelude::*};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
pub db: DatabaseConnection,
|
||||
template_loader: Arc<AutoReloader>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(db: DatabaseConnection, template_loader: AutoReloader) -> Context {
|
||||
Context {
|
||||
db,
|
||||
template_loader: Arc::new(template_loader),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render<T: Serialize>(&self, path: &str, data: T) -> anyhow::Result<String> {
|
||||
// TODO: more graceful handling of the potential errors here; this should not use anyhow
|
||||
let env = self.template_loader.acquire_env().unwrap();
|
||||
let template = env.get_template(path)?;
|
||||
let rendered = template.render(data)?;
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
pub fn render_resp<T: Serialize>(&self, path: &str, data: T) -> Response {
|
||||
let rendered = self.render(path, data);
|
||||
match rendered {
|
||||
Ok(rendered) => Html(rendered).into_response(),
|
||||
Err(err) => {
|
||||
error!(?err, "error while rendering template");
|
||||
internal_server_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
125
src/db.rs
125
src/db.rs
|
@ -1,96 +1,43 @@
|
|||
use sea_orm::Set;
|
||||
use thiserror::Error;
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use diesel::SqliteConnection;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
|
||||
use crate::{entity::user, password, prelude::*};
|
||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations/");
|
||||
|
||||
pub struct NewUser {
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Establishes a connection to the database using the given URL.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - The database URL to connect to.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the database URL is not set or if the connection cannot be
|
||||
/// established.
|
||||
pub fn establish_connection(url: &str) -> SqliteConnection {
|
||||
SqliteConnection::establish(url).unwrap_or_else(|_| panic!("Error connecting to {}", url))
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
|
||||
let mut validation_errors = vec![];
|
||||
|
||||
if self.full_name.len() > 100 {
|
||||
validation_errors.push(ValidationError::on("full_name", "too long (max=100)"));
|
||||
/// Builds a connection pool for the given URL.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - The database URL to connect to.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the connection pool cannot be created.
|
||||
pub fn build_connection_pool(url: &str) -> Pool<ConnectionManager<SqliteConnection>> {
|
||||
let manager = ConnectionManager::<SqliteConnection>::new(url);
|
||||
Pool::builder().build(manager).expect("Failed to create connection pool.")
|
||||
}
|
||||
|
||||
if self.email.len() > 100 {
|
||||
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
|
||||
}
|
||||
|
||||
if self.username.len() > 100 {
|
||||
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
|
||||
}
|
||||
|
||||
if validation_errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(validation_errors)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_password(&self) -> String {
|
||||
password::hash(&self.password)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ValidationError {
|
||||
pub field: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ValidationError {
|
||||
pub fn on(field: &str, message: &str) -> ValidationError {
|
||||
ValidationError {
|
||||
field: field.to_owned(),
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
#[error("internal database error")]
|
||||
Internal(#[from] sea_orm::DbErr),
|
||||
}
|
||||
|
||||
pub struct UserQuery<'a>(pub &'a DatabaseConnection);
|
||||
|
||||
impl UserQuery<'_> {
|
||||
pub async fn insert(&self, new_user: NewUser) -> Result<user::ActiveModel, DbError> {
|
||||
let password_hash = new_user.hash_password();
|
||||
let user = user::ActiveModel {
|
||||
full_name: Set(new_user.full_name),
|
||||
email: Set(new_user.email),
|
||||
username: Set(new_user.username),
|
||||
password_hash: Set(password_hash),
|
||||
..Default::default()
|
||||
}
|
||||
.save(self.0)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn all(&self) -> Result<Vec<user::Model>, DbError> {
|
||||
let users = User::find().all(self.0).await?;
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
pub async fn by_id(&self, id: i32) -> Result<Option<user::Model>, DbError> {
|
||||
let user = User::find_by_id(id).one(self.0).await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn by_username(&self, username: &str) -> Result<Option<user::Model>, DbError> {
|
||||
let user = User::find()
|
||||
.filter(user::Column::Username.eq(username))
|
||||
.one(self.0)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
/// Runs any pending migrations.
|
||||
///
|
||||
/// This function should be called before the application starts.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `conn` - The database connection to run the migrations on.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if there is an error running the migrations.
|
||||
pub fn migrate(conn: &mut SqliteConnection) {
|
||||
conn.run_pending_migrations(MIGRATIONS).unwrap();
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod user;
|
|
@ -1,3 +0,0 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
pub use super::user::Entity as User;
|
|
@ -1,22 +0,0 @@
|
|||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.15
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub full_name: String,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
|
@ -1,14 +1,16 @@
|
|||
pub mod documents;
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod projects;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
pub use login::login_page;
|
||||
pub use login::login_submit;
|
||||
pub use login::{login_page, login_submit};
|
||||
use tracing::error;
|
||||
|
||||
pub fn internal_server_error() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body("Internal Server Error".into())
|
||||
.unwrap()
|
||||
pub fn internal_error<E>(err: E) -> (StatusCode, String)
|
||||
where
|
||||
E: std::error::Error,
|
||||
{
|
||||
error!(?err, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error".into())
|
||||
}
|
||||
|
|
220
src/handler/documents.rs
Normal file
220
src/handler/documents.rs
Normal file
|
@ -0,0 +1,220 @@
|
|||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Redirect;
|
||||
use axum::Form;
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use crate::handler::internal_error;
|
||||
use crate::models::documents::{self, NewDocument};
|
||||
use crate::models::users::User;
|
||||
use crate::permissions::q::{Decision, Permission};
|
||||
use crate::permissions::{self};
|
||||
use crate::prelude::*;
|
||||
|
||||
pub async fn documents_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if let Some(user) = auth_session.user {
|
||||
render_documents_page(provider, user).await
|
||||
} else {
|
||||
Ok(Redirect::to("/login").into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_documents_page(
|
||||
provider: Provider,
|
||||
user: User,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
let documents =
|
||||
permissions::q::accessible_documents(&mut db, &user.id).map_err(internal_error)?;
|
||||
let projects =
|
||||
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
documents => documents,
|
||||
projects => projects,
|
||||
};
|
||||
|
||||
provider.render_resp("documents/list_documents.html", values)
|
||||
}
|
||||
|
||||
pub async fn create_document_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
|
||||
let projects =
|
||||
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
projects => projects,
|
||||
};
|
||||
provider.render_resp("documents/create_document.html", values)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDocumentSubmission {
|
||||
pub project_id: Uuid,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
pub async fn create_document_submit(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
form: Form<CreateDocumentSubmission>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
|
||||
let access_decision = permissions::q::check_user_project(
|
||||
&mut db,
|
||||
&user.id,
|
||||
&form.project_id.to_string(),
|
||||
Permission::Write,
|
||||
)
|
||||
.map_err(internal_error)?;
|
||||
|
||||
if matches!(access_decision, Decision::Denied) {
|
||||
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||
}
|
||||
|
||||
let new_document = NewDocument::new(
|
||||
&user.id,
|
||||
&form.project_id.to_string(),
|
||||
form.title.to_owned(),
|
||||
"".to_owned(),
|
||||
);
|
||||
|
||||
let document = documents::q::create(&mut db, new_document).map_err(internal_error)?;
|
||||
info!(?document, "document created");
|
||||
|
||||
Ok(Redirect::to("/documents").into_response())
|
||||
}
|
||||
|
||||
pub async fn view_document_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
Path((id,)): Path<(Uuid,)>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
|
||||
let access_decision =
|
||||
permissions::q::check_user_document(&mut db, &user.id, &id.to_string(), Permission::Write)
|
||||
.map_err(internal_error)?;
|
||||
|
||||
if matches!(access_decision, Decision::Denied) {
|
||||
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||
}
|
||||
|
||||
let document = match documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)? {
|
||||
Some(doc) => doc,
|
||||
None => return Err((StatusCode::NOT_FOUND, "document not found".to_owned())),
|
||||
};
|
||||
let projects =
|
||||
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||
|
||||
let rendered_document = document.render_html();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
document => document,
|
||||
projects => projects,
|
||||
rendered_document => rendered_document,
|
||||
};
|
||||
|
||||
provider.render_resp("documents/view_document.html", values)
|
||||
}
|
||||
|
||||
pub async fn edit_document_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
Path((id,)): Path<(Uuid,)>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
|
||||
let access_decision =
|
||||
permissions::q::check_user_document(&mut db, &user.id, &id.to_string(), Permission::Write)
|
||||
.map_err(internal_error)?;
|
||||
|
||||
if matches!(access_decision, Decision::Denied) {
|
||||
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||
}
|
||||
|
||||
let document = documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)?;
|
||||
let projects =
|
||||
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
document => document,
|
||||
projects => projects,
|
||||
};
|
||||
|
||||
provider.render_resp("documents/edit_document.html", values)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EditDocumentSubmission {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub async fn edit_document_submit(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
Path((document_id,)): Path<(Uuid,)>,
|
||||
form: Form<EditDocumentSubmission>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
|
||||
let access_decision = permissions::q::check_user_document(
|
||||
&mut db,
|
||||
&user.id,
|
||||
&document_id.to_string(),
|
||||
Permission::Write,
|
||||
)
|
||||
.map_err(internal_error)?;
|
||||
|
||||
if matches!(access_decision, Decision::Denied) {
|
||||
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
|
||||
}
|
||||
|
||||
documents::q::update(
|
||||
&mut db,
|
||||
&document_id.to_string(),
|
||||
form.title.to_owned(),
|
||||
form.content.to_owned(),
|
||||
)
|
||||
.map_err(internal_error)?;
|
||||
|
||||
let view_url = format!("/documents/view/{}", document_id);
|
||||
Ok(Redirect::to(&view_url).into_response())
|
||||
}
|
|
@ -1,16 +1,27 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::Redirect;
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use crate::models::projects::Project;
|
||||
use crate::permissions;
|
||||
use crate::prelude::*;
|
||||
|
||||
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
|
||||
pub async fn home_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if let Some(user) = auth_session.user {
|
||||
let mut db = provider.db_pool.get().unwrap();
|
||||
let projects: Vec<Project> =
|
||||
permissions::q::accessible_projects(&mut db, &user.id).unwrap();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
projects => projects,
|
||||
};
|
||||
|
||||
ctx.render_resp("home.html", values)
|
||||
provider.render_resp("home.html", values)
|
||||
} else {
|
||||
Redirect::to("/login").into_response()
|
||||
Ok(Redirect::to("/login").into_response())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
use axum::{response::Redirect, Form};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Redirect;
|
||||
use axum::Form;
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use crate::{handler::internal_server_error, prelude::*, session::Credentials};
|
||||
use super::internal_error;
|
||||
use crate::prelude::*;
|
||||
use crate::session::Credentials;
|
||||
|
||||
pub struct LoginTemplate {
|
||||
pub username: String,
|
||||
|
@ -10,23 +14,23 @@ pub struct LoginTemplate {
|
|||
}
|
||||
|
||||
pub async fn login_page(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
) -> Response {
|
||||
if auth_session.user.is_some() {
|
||||
return Redirect::to("/").into_response();
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if let Some(_user) = auth_session.user {
|
||||
Ok(Redirect::to("/").into_response())
|
||||
} else {
|
||||
render_login_page(&provider, "", "", None)
|
||||
}
|
||||
|
||||
render_login_page(&ctx, "", "", None)
|
||||
}
|
||||
|
||||
fn render_login_page(
|
||||
ctx: &Context,
|
||||
provider: &Provider,
|
||||
username: &str,
|
||||
password: &str,
|
||||
error: Option<&'static str>,
|
||||
) -> Response {
|
||||
ctx.render_resp(
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
provider.render_resp(
|
||||
"login.html",
|
||||
context! {
|
||||
username => username,
|
||||
|
@ -39,83 +43,22 @@ fn render_login_page(
|
|||
const LOGIN_ERROR_MSG: &str = "Invalid username or password";
|
||||
|
||||
pub async fn login_submit(
|
||||
State(ctx): State<Context>,
|
||||
mut auth_session: AuthSession<Context>,
|
||||
State(provider): State<Provider>,
|
||||
mut auth_session: AuthSession<Provider>,
|
||||
Form(creds): Form<Credentials>,
|
||||
) -> Response {
|
||||
match auth_session.authenticate(creds).await {
|
||||
Ok(Some(user)) => {
|
||||
if let Err(err) = auth_session.login(&user).await {
|
||||
error!(?err, "error while logging in user");
|
||||
return internal_server_error();
|
||||
}
|
||||
|
||||
Redirect::to("/").into_response()
|
||||
}
|
||||
Ok(None) => render_login_page(&ctx, "", "", Some(LOGIN_ERROR_MSG)),
|
||||
Err(err) => {
|
||||
error!(?err, "error while authenticating user");
|
||||
internal_server_error()
|
||||
}
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if let Some(user) = auth_session.authenticate(creds).await.map_err(internal_error)? {
|
||||
auth_session.login(&user).await.map_err(internal_error)?;
|
||||
Ok(Redirect::to("/").into_response())
|
||||
} else {
|
||||
render_login_page(&provider, "", "", Some(LOGIN_ERROR_MSG))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(mut auth_session: AuthSession<Context>) -> Response {
|
||||
pub async fn logout(mut auth_session: AuthSession<Provider>) -> Response {
|
||||
if let Err(err) = auth_session.logout().await {
|
||||
error!(?err, "error while logging out user");
|
||||
}
|
||||
|
||||
Redirect::to("/login").into_response()
|
||||
}
|
||||
|
||||
//const INVALID_LOGIN_MESSAGE: &str = "Invalid username/password, please try again.";
|
||||
//
|
||||
//pub async fn login_submission(
|
||||
// request: HttpRequest,
|
||||
// context: web::Data<Context>,
|
||||
// form: web::Form<LoginForm>,
|
||||
//) -> impl Responder {
|
||||
// let mut conn = match context.pool.get() {
|
||||
// Ok(conn) => conn,
|
||||
// Err(_) => return internal_server_error(),
|
||||
// };
|
||||
//
|
||||
// let user = match fetch_user_by_username(&mut conn, &form.username) {
|
||||
// Ok(Some(user)) => user,
|
||||
// Ok(None) => {
|
||||
// return LoginTemplate {
|
||||
// username: form.username.clone(),
|
||||
// password: String::new(),
|
||||
// error: Some(INVALID_LOGIN_MESSAGE.into()),
|
||||
// }
|
||||
// .to_response()
|
||||
// }
|
||||
// Err(_) => return internal_server_error(),
|
||||
// };
|
||||
//
|
||||
// if !user.check_password(&form.password) {
|
||||
// return LoginTemplate {
|
||||
// username: form.username.clone(),
|
||||
// password: String::new(),
|
||||
// error: Some(INVALID_LOGIN_MESSAGE.into()),
|
||||
// }
|
||||
// .to_response();
|
||||
// }
|
||||
//
|
||||
// if Identity::login(&request.extensions(), user.id.to_string()).is_err() {
|
||||
// return internal_server_error();
|
||||
// }
|
||||
//
|
||||
// return HttpResponse::Found()
|
||||
// .append_header(("Location", "/"))
|
||||
// .finish();
|
||||
//}
|
||||
//
|
||||
//#[get("/logout")]
|
||||
//pub async fn logout(user: Option<Identity>) -> impl Responder {
|
||||
// if let Some(user) = user {
|
||||
// user.logout();
|
||||
// }
|
||||
//
|
||||
// redirect_to_login()
|
||||
//}
|
||||
|
|
87
src/handler/projects.rs
Normal file
87
src/handler/projects.rs
Normal file
|
@ -0,0 +1,87 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::Redirect;
|
||||
use axum::Form;
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use super::internal_error;
|
||||
use crate::models::project_memberships::{self, ProjectRole};
|
||||
use crate::models::projects::{self, NewProject};
|
||||
use crate::models::users::User;
|
||||
use crate::permissions;
|
||||
use crate::prelude::*;
|
||||
|
||||
pub async fn projects_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if let Some(user) = auth_session.user {
|
||||
render_projects_page(provider, user).await
|
||||
} else {
|
||||
Ok(Redirect::to("/login").into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_projects_page(
|
||||
provider: Provider,
|
||||
user: User,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
let projects = permissions::q::accessible_projects(&mut db, &user.id).unwrap_or_default();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
projects => projects,
|
||||
};
|
||||
|
||||
provider.render_resp("projects/list_projects.html", values)
|
||||
}
|
||||
|
||||
pub async fn create_project_page(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
};
|
||||
provider.render_resp("projects/create_project.html", values)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProjectSubmission {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
pub async fn create_project_submit(
|
||||
State(provider): State<Provider>,
|
||||
auth_session: AuthSession<Provider>,
|
||||
form: Form<CreateProjectSubmission>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let mut db = provider.db_pool.get().map_err(internal_error)?;
|
||||
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Ok(Redirect::to("/login").into_response()),
|
||||
};
|
||||
|
||||
let new_project = NewProject::new(
|
||||
user.id.clone(),
|
||||
form.name.clone(),
|
||||
form.description.clone(),
|
||||
form.key.clone(),
|
||||
);
|
||||
// TODO: validation
|
||||
|
||||
let project = projects::q::create(&mut db, new_project).map_err(internal_error)?;
|
||||
|
||||
let _ = project_memberships::q::create(&mut db, &user.id, &project.id, ProjectRole::Admin)
|
||||
.map_err(internal_error)?;
|
||||
|
||||
Ok(Redirect::to("/projects").into_response())
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod db;
|
||||
pub mod entity;
|
||||
pub mod handler;
|
||||
pub mod logging;
|
||||
pub mod models;
|
||||
pub mod password;
|
||||
pub mod permissions;
|
||||
pub mod prelude;
|
||||
pub mod provider;
|
||||
pub mod schema;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod templates;
|
||||
pub mod validation;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
pub fn setup_logging() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init();
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
18
src/models.rs
Normal file
18
src/models.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
use thiserror::Error;
|
||||
|
||||
pub mod documents;
|
||||
pub mod project_memberships;
|
||||
pub mod projects;
|
||||
pub mod users;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DbError {
|
||||
#[error("Diesel error: {0}")]
|
||||
DieselError(#[from] diesel::result::Error),
|
||||
|
||||
#[error("Diesel connection error: {0}")]
|
||||
ConnectionError(#[from] diesel::ConnectionError),
|
||||
|
||||
#[error("Connection pool error: {0}")]
|
||||
PoolError(#[from] diesel::r2d2::PoolError),
|
||||
}
|
108
src/models/documents.rs
Normal file
108
src/models/documents.rs
Normal file
|
@ -0,0 +1,108 @@
|
|||
use diesel::prelude::*;
|
||||
use pulldown_cmark as markdown;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::DbError;
|
||||
use crate::schema::documents::dsl;
|
||||
|
||||
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
|
||||
#[diesel(table_name = crate::schema::documents)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
pub creator_id: String,
|
||||
pub project_id: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn render_html(&self) -> String {
|
||||
let mut options = markdown::Options::empty();
|
||||
options.insert(markdown::Options::ENABLE_TASKLISTS);
|
||||
options.insert(markdown::Options::ENABLE_STRIKETHROUGH);
|
||||
|
||||
let parser = markdown::Parser::new_ext(&self.content, options);
|
||||
|
||||
// If we just process things as they are, we are vulnerable to XSS
|
||||
// attacks, since users can inject any HTML they'd like. To prevent
|
||||
// this, we convert any parsed HTML to just text. In the future, we can
|
||||
// instead sanitize the HTML using something like
|
||||
// [ammonia](https://crates.io/crates/ammonia) to make the HTML safer.
|
||||
// Draws inspiration from
|
||||
// [pulldown-cmark/pulldown-cmark#608](https://github.com/pulldown-cmark/pulldown-cmark/issues/608)
|
||||
let escaped = parser.into_iter().map(|event| match event {
|
||||
markdown::Event::Html(html) => markdown::Event::Text(html),
|
||||
markdown::Event::InlineHtml(html) => markdown::Event::Text(html),
|
||||
_ => event,
|
||||
});
|
||||
|
||||
let mut html_output = String::new();
|
||||
markdown::html::push_html(&mut html_output, escaped);
|
||||
|
||||
html_output
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = crate::schema::documents)]
|
||||
pub struct NewDocument {
|
||||
pub id: String,
|
||||
pub creator_id: String,
|
||||
pub project_id: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl NewDocument {
|
||||
pub fn new(creator_id: &str, project_id: &str, title: String, content: String) -> Self {
|
||||
Self {
|
||||
id: Uuid::now_v7().to_string(),
|
||||
creator_id: creator_id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
title,
|
||||
content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod q {
|
||||
use super::*;
|
||||
|
||||
pub fn create(
|
||||
conn: &mut SqliteConnection,
|
||||
new_document: NewDocument,
|
||||
) -> Result<Document, DbError> {
|
||||
diesel::insert_into(dsl::documents).values(&new_document).execute(conn)?;
|
||||
|
||||
let document = dsl::documents.filter(dsl::id.eq(&new_document.id)).first(conn)?;
|
||||
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
conn: &mut SqliteConnection,
|
||||
document_id: &str,
|
||||
title: String,
|
||||
content: String,
|
||||
) -> Result<Document, DbError> {
|
||||
diesel::update(dsl::documents.filter(dsl::id.eq(document_id)))
|
||||
.set((dsl::title.eq(title), dsl::content.eq(content)))
|
||||
.execute(conn)?;
|
||||
|
||||
let document = dsl::documents.filter(dsl::id.eq(document_id)).first(conn)?;
|
||||
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
pub fn by_id(
|
||||
conn: &mut SqliteConnection,
|
||||
document_id: &str,
|
||||
) -> Result<Option<Document>, DbError> {
|
||||
let document =
|
||||
dsl::documents.filter(dsl::id.eq(document_id)).first::<Document>(conn).optional()?;
|
||||
|
||||
Ok(document)
|
||||
}
|
||||
}
|
92
src/models/project_memberships.rs
Normal file
92
src/models/project_memberships.rs
Normal file
|
@ -0,0 +1,92 @@
|
|||
use std::fmt;
|
||||
|
||||
use diesel::expression::AsExpression;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sql_types::Text;
|
||||
|
||||
#[derive(AsExpression, Debug, Clone)]
|
||||
#[diesel(sql_type = Text)]
|
||||
pub enum ProjectRole {
|
||||
Member,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectRole {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ProjectRole::Member => write!(f, "member"),
|
||||
ProjectRole::Admin => write!(f, "admin"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> From<S> for ProjectRole
|
||||
where
|
||||
S: AsRef<str>,
|
||||
String: std::convert::From<S>,
|
||||
{
|
||||
fn from(status: S) -> Self {
|
||||
match status.as_ref() {
|
||||
"member" => ProjectRole::Member,
|
||||
"admin" => ProjectRole::Admin,
|
||||
_ => ProjectRole::Member,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProjectRole> for String {
|
||||
fn from(role: ProjectRole) -> Self {
|
||||
match role {
|
||||
ProjectRole::Member => "member".to_string(),
|
||||
ProjectRole::Admin => "admin".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Queryable, Selectable, Debug, Clone)]
|
||||
#[diesel(table_name = crate::schema::project_memberships)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct ProjectMembership {
|
||||
pub id: i32,
|
||||
pub user_id: String,
|
||||
pub project_id: String,
|
||||
|
||||
#[diesel(serialize_as = String, deserialize_as = String)]
|
||||
pub role: ProjectRole,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = crate::schema::project_memberships)]
|
||||
pub struct NewProjectMembership {
|
||||
pub user_id: String,
|
||||
pub project_id: String,
|
||||
|
||||
#[diesel(serialize_as = String, deserialize_as = String)]
|
||||
pub role: ProjectRole,
|
||||
}
|
||||
|
||||
pub mod q {
|
||||
use diesel::SqliteConnection;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn create(
|
||||
conn: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
role: ProjectRole,
|
||||
) -> Result<ProjectMembership, diesel::result::Error> {
|
||||
use crate::schema::project_memberships::dsl as pm;
|
||||
|
||||
let new_membership = NewProjectMembership {
|
||||
user_id: user_id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
role,
|
||||
};
|
||||
|
||||
let membership =
|
||||
diesel::insert_into(pm::project_memberships).values(new_membership).get_result(conn)?;
|
||||
|
||||
Ok(membership)
|
||||
}
|
||||
}
|
54
src/models/projects.rs
Normal file
54
src/models/projects.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
use diesel::prelude::*;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::DbError;
|
||||
use crate::schema::projects::dsl;
|
||||
|
||||
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
|
||||
#[diesel(table_name = crate::schema::projects)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
pub creator_id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = crate::schema::projects)]
|
||||
pub struct NewProject {
|
||||
pub id: String,
|
||||
pub creator_id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl NewProject {
|
||||
pub fn new(creator_id: String, name: String, description: String, key: String) -> Self {
|
||||
Self { id: Uuid::now_v7().to_string(), creator_id, name, description, key }
|
||||
}
|
||||
}
|
||||
|
||||
pub mod q {
|
||||
use super::*;
|
||||
|
||||
pub fn for_user(conn: &mut SqliteConnection, user_id: String) -> Result<Vec<Project>, DbError> {
|
||||
let projects =
|
||||
dsl::projects.filter(dsl::creator_id.eq(user_id.to_string())).load::<Project>(conn)?;
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
conn: &mut SqliteConnection,
|
||||
new_project: NewProject,
|
||||
) -> Result<Project, diesel::result::Error> {
|
||||
use crate::schema::projects::dsl as p;
|
||||
|
||||
let project = diesel::insert_into(p::projects).values(new_project).get_result(conn)?;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
}
|
85
src/models/users.rs
Normal file
85
src/models/users.rs
Normal file
|
@ -0,0 +1,85 @@
|
|||
use diesel::prelude::*;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::DbError;
|
||||
use crate::password;
|
||||
use crate::schema::users::dsl;
|
||||
use crate::validation::ValidationError;
|
||||
|
||||
#[derive(Queryable, Selectable, Debug, Clone, Serialize)]
|
||||
#[diesel(table_name = crate::schema::users)]
|
||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = crate::schema::users)]
|
||||
pub struct NewUser {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
pub fn new(name: String, username: String, email: String, password: String) -> Self {
|
||||
let password_hash = password::hash(&password);
|
||||
Self { id: Uuid::now_v7().to_string(), name, username, email, password_hash }
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
|
||||
let mut validation_errors = vec![];
|
||||
|
||||
if self.name.len() > 100 {
|
||||
validation_errors.push(ValidationError::on("name", "too long (max=100)"));
|
||||
}
|
||||
|
||||
if self.email.len() > 100 {
|
||||
validation_errors.push(ValidationError::on("email", "too long (max=100)"));
|
||||
}
|
||||
|
||||
if self.username.len() > 32 {
|
||||
validation_errors.push(ValidationError::on("username", "too long (max=32)"));
|
||||
}
|
||||
|
||||
if validation_errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(validation_errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod q {
|
||||
use super::*;
|
||||
|
||||
pub fn all(conn: &mut SqliteConnection) -> Result<Vec<User>, DbError> {
|
||||
let user_list = dsl::users.load::<User>(conn)?;
|
||||
Ok(user_list)
|
||||
}
|
||||
|
||||
pub fn by_id(conn: &mut SqliteConnection, id: &str) -> Result<User, DbError> {
|
||||
let user = dsl::users.filter(dsl::id.eq(id)).first::<User>(conn)?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub fn by_username(conn: &mut SqliteConnection, username: &str) -> Result<User, DbError> {
|
||||
let user = dsl::users.filter(dsl::username.eq(username)).first::<User>(conn)?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub fn create(conn: &mut SqliteConnection, new_user: NewUser) -> Result<User, DbError> {
|
||||
let _ = diesel::insert_into(dsl::users).values(&new_user).execute(conn)?;
|
||||
|
||||
let new_user = dsl::users.filter(dsl::id.eq(&new_user.id)).first::<User>(conn)?;
|
||||
|
||||
Ok(new_user)
|
||||
}
|
||||
}
|
|
@ -9,18 +9,14 @@ pub fn verify(hash: &str, password: &str) -> bool {
|
|||
Err(_) => return false, // TODO: log an error
|
||||
};
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok()
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
|
||||
}
|
||||
|
||||
/// Hashes the given password.
|
||||
pub fn hash(password: &str) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.unwrap();
|
||||
let hash = Argon2::default().hash_password(password.as_bytes(), &salt).unwrap();
|
||||
|
||||
hash.to_string()
|
||||
}
|
||||
|
|
143
src/permissions.rs
Normal file
143
src/permissions.rs
Normal file
|
@ -0,0 +1,143 @@
|
|||
pub mod q {
|
||||
use diesel::prelude::*;
|
||||
use diesel::SqliteConnection;
|
||||
|
||||
use crate::models::documents::Document;
|
||||
use crate::models::project_memberships::ProjectRole;
|
||||
use crate::models::projects::Project;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Permission {
|
||||
Read,
|
||||
Write,
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Decision {
|
||||
Allowed,
|
||||
Denied,
|
||||
}
|
||||
|
||||
pub fn check_user_project(
|
||||
db: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
requested_permission: Permission,
|
||||
) -> Result<Decision, diesel::result::Error> {
|
||||
use crate::schema::project_memberships::dsl as pm;
|
||||
|
||||
let row_count = match requested_permission {
|
||||
Permission::Admin => pm::project_memberships
|
||||
.filter(pm::user_id.eq(user_id))
|
||||
.filter(pm::project_id.eq(project_id))
|
||||
.filter(pm::role.eq(ProjectRole::Admin.to_string()))
|
||||
.count()
|
||||
.get_result::<i64>(db)?,
|
||||
_ => pm::project_memberships
|
||||
.filter(pm::user_id.eq(user_id))
|
||||
.filter(pm::project_id.eq(project_id))
|
||||
.count()
|
||||
.get_result::<i64>(db)?,
|
||||
};
|
||||
|
||||
if row_count > 0 {
|
||||
if row_count > 1 {
|
||||
tracing::error!(
|
||||
row_count = row_count,
|
||||
user_id = user_id,
|
||||
project_id = project_id,
|
||||
"unexpected row count: more than one project membership for this user"
|
||||
)
|
||||
}
|
||||
Ok(Decision::Allowed)
|
||||
} else {
|
||||
Ok(Decision::Denied)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_user_document(
|
||||
db: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
document_id: &str,
|
||||
permission: Permission,
|
||||
) -> Result<Decision, diesel::result::Error> {
|
||||
use crate::schema::documents::dsl as d;
|
||||
|
||||
let document =
|
||||
d::documents.filter(d::id.eq(document_id)).first::<Document>(db).optional()?;
|
||||
|
||||
match document {
|
||||
Some(doc) => check_user_project(db, user_id, &doc.project_id, permission),
|
||||
None => Ok(Decision::Denied),
|
||||
}
|
||||
}
|
||||
|
||||
/// Users have permissions directly on projects which they are members of.
|
||||
pub fn accessible_project_ids(
|
||||
db: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<String>, diesel::result::Error> {
|
||||
use crate::schema::project_memberships::dsl as pm;
|
||||
|
||||
let project_ids = pm::project_memberships
|
||||
.filter(pm::user_id.eq(user_id))
|
||||
.select(pm::project_id)
|
||||
.load::<String>(db)?;
|
||||
|
||||
Ok(project_ids)
|
||||
}
|
||||
|
||||
/// Users have permissions directly on projects which they are members of.
|
||||
pub fn accessible_projects(
|
||||
db: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<Project>, diesel::result::Error> {
|
||||
use crate::schema::projects::dsl as p;
|
||||
|
||||
let project_ids = accessible_project_ids(db, user_id)?;
|
||||
let projects = p::projects.filter(p::id.eq_any(project_ids)).load::<Project>(db)?;
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
/// Users can access documents which they are members of or which are in
|
||||
/// projects they're members of.
|
||||
pub fn accessible_document_ids(
|
||||
db: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<String>, diesel::result::Error> {
|
||||
use crate::schema::documents::dsl as d;
|
||||
use crate::schema::project_memberships::dsl as pm;
|
||||
|
||||
let project_ids = accessible_project_ids(db, user_id)?;
|
||||
|
||||
let direct_documents = pm::project_memberships
|
||||
.filter(pm::user_id.eq(user_id))
|
||||
.select(pm::project_id)
|
||||
.load::<String>(db)?;
|
||||
|
||||
let project_documents = d::documents
|
||||
.filter(d::project_id.eq_any(project_ids))
|
||||
.select(d::id)
|
||||
.load::<String>(db)?;
|
||||
|
||||
let document_ids = direct_documents.into_iter().chain(project_documents).collect();
|
||||
|
||||
Ok(document_ids)
|
||||
}
|
||||
|
||||
/// Users can access documents which they are members of or which are in
|
||||
/// projects they're members of.
|
||||
pub fn accessible_documents(
|
||||
db: &mut SqliteConnection,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<Document>, diesel::result::Error> {
|
||||
use crate::schema::documents::dsl as d;
|
||||
|
||||
let document_ids = accessible_document_ids(db, user_id)?;
|
||||
let documents = d::documents.filter(d::id.eq_any(document_ids)).load::<Document>(db)?;
|
||||
|
||||
Ok(documents)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
pub use crate::context::Context;
|
||||
pub use crate::entity::prelude::*;
|
||||
pub use axum::extract::State;
|
||||
pub use axum::response::{Html, IntoResponse, Response};
|
||||
pub use minijinja::context;
|
||||
pub use sea_orm::prelude::*;
|
||||
pub use sea_orm::{ActiveModelTrait, DatabaseConnection};
|
||||
pub use serde::{Deserialize, Serialize};
|
||||
pub use tracing::{debug, error, info, warn};
|
||||
pub use uuid::Uuid;
|
||||
|
||||
pub use crate::provider::Provider;
|
||||
|
|
55
src/provider.rs
Normal file
55
src/provider.rs
Normal file
|
@ -0,0 +1,55 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use diesel::SqliteConnection;
|
||||
use minijinja_autoreload::AutoReloader;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::handler::internal_error;
|
||||
use crate::prelude::*;
|
||||
|
||||
pub type ConnectionPool = Pool<ConnectionManager<SqliteConnection>>;
|
||||
pub type PooledConnection = diesel::r2d2::PooledConnection<ConnectionManager<SqliteConnection>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Provider {
|
||||
pub db_pool: ConnectionPool,
|
||||
template_loader: Arc<AutoReloader>,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProviderError {
|
||||
#[error("Error while using the connection pool: {0}")]
|
||||
R2D2Error(#[from] diesel::r2d2::PoolError),
|
||||
|
||||
#[error("Error while rendering template: {0}")]
|
||||
TemplateError(#[from] minijinja::Error),
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn new(db: ConnectionPool, template_loader: AutoReloader) -> Provider {
|
||||
Provider { db_pool: db, template_loader: Arc::new(template_loader) }
|
||||
}
|
||||
|
||||
pub fn db_conn(&self) -> Result<PooledConnection, ProviderError> {
|
||||
let conn = self.db_pool.get()?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn render<T: Serialize>(&self, path: &str, data: T) -> Result<String, ProviderError> {
|
||||
let env = self.template_loader.acquire_env().unwrap();
|
||||
let template = env.get_template(path)?;
|
||||
let rendered = template.render(data)?;
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
pub fn render_resp<T: Serialize>(
|
||||
&self,
|
||||
path: &str,
|
||||
data: T,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let rendered = self.render(path, data).map_err(internal_error)?;
|
||||
Ok(Html(rendered).into_response())
|
||||
}
|
||||
}
|
42
src/schema.rs
Normal file
42
src/schema.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
documents (id) {
|
||||
id -> Text,
|
||||
creator_id -> Text,
|
||||
project_id -> Text,
|
||||
title -> Text,
|
||||
content -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
project_memberships (id) {
|
||||
id -> Integer,
|
||||
user_id -> Text,
|
||||
project_id -> Text,
|
||||
role -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
projects (id) {
|
||||
id -> Text,
|
||||
creator_id -> Text,
|
||||
name -> Text,
|
||||
description -> Text,
|
||||
key -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
users (id) {
|
||||
id -> Text,
|
||||
username -> Text,
|
||||
password_hash -> Text,
|
||||
email -> Text,
|
||||
name -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(documents, project_memberships, projects, users,);
|
|
@ -1,16 +1,30 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use axum_login::AuthManagerLayerBuilder;
|
||||
use clap::Parser;
|
||||
use sea_orm::Database;
|
||||
use tower_http::{services::ServeDir, trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}};
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
|
||||
use tower_sessions_sqlx_store::sqlx::SqlitePool;
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
use tracing::Level;
|
||||
|
||||
use crate::{config::CommandLineOptions, context::Context, handler::{home::home_page, login::logout, login_page, login_submit}, logging::setup_logging, templates::make_template_loader};
|
||||
use crate::config::CommandLineOptions;
|
||||
use crate::db;
|
||||
use crate::handler::documents::{
|
||||
create_document_page, create_document_submit, documents_page, edit_document_page,
|
||||
edit_document_submit, view_document_page,
|
||||
};
|
||||
use crate::handler::home::home_page;
|
||||
use crate::handler::login::logout;
|
||||
use crate::handler::projects::{create_project_page, create_project_submit, projects_page};
|
||||
use crate::handler::{login_page, login_submit};
|
||||
use crate::logging::setup_logging;
|
||||
use crate::provider::Provider;
|
||||
use crate::templates::make_template_loader;
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
dotenvy::dotenv()?;
|
||||
|
@ -21,13 +35,16 @@ pub async fn run() -> Result<()> {
|
|||
let template_loader = make_template_loader(opts.reload_templates);
|
||||
|
||||
let db_url = dotenvy::var("DATABASE_URL")?;
|
||||
let db = Database::connect(db_url).await?;
|
||||
let mut db_conn = db::establish_connection(&db_url);
|
||||
db::migrate(&mut db_conn);
|
||||
|
||||
let db_pool = db::build_connection_pool(&db_url);
|
||||
|
||||
let session_layer = create_session_manager_layer().await?;
|
||||
|
||||
let context = Context::new(db, template_loader);
|
||||
let provider = Provider::new(db_pool, template_loader);
|
||||
|
||||
let auth_backend = context.clone();
|
||||
let auth_backend = provider.clone();
|
||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
|
||||
|
||||
let trace_layer = TraceLayer::new_for_http()
|
||||
|
@ -40,10 +57,19 @@ pub async fn run() -> Result<()> {
|
|||
.route("/login", get(login_page))
|
||||
.route("/login", post(login_submit))
|
||||
.route("/logout", get(logout))
|
||||
.route("/projects", get(projects_page))
|
||||
.route("/projects/new", get(create_project_page))
|
||||
.route("/projects/new", post(create_project_submit))
|
||||
.route("/documents", get(documents_page))
|
||||
.route("/documents/new", get(create_document_page))
|
||||
.route("/documents/new", post(create_document_submit))
|
||||
.route("/documents/view/:id", get(view_document_page))
|
||||
.route("/documents/edit/:id", get(edit_document_page))
|
||||
.route("/documents/edit/:id", post(edit_document_submit))
|
||||
.layer(trace_layer)
|
||||
.layer(session_layer)
|
||||
.layer(auth_layer)
|
||||
.with_state(context);
|
||||
.with_state(provider);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
|
|
|
@ -1,24 +1,21 @@
|
|||
use async_trait::async_trait;
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
|
||||
use crate::{
|
||||
db::{DbError, UserQuery},
|
||||
entity::user,
|
||||
password,
|
||||
prelude::*,
|
||||
};
|
||||
use crate::models::{self, users, DbError};
|
||||
use crate::password;
|
||||
use crate::prelude::*;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl AuthUser for user::Model {
|
||||
type Id = i32;
|
||||
impl AuthUser for models::users::User {
|
||||
type Id = String;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
|
@ -27,8 +24,8 @@ impl AuthUser for user::Model {
|
|||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthnBackend for Context {
|
||||
type User = user::Model;
|
||||
impl AuthnBackend for Provider {
|
||||
type User = models::users::User;
|
||||
type Credentials = Credentials;
|
||||
type Error = DbError;
|
||||
|
||||
|
@ -36,14 +33,20 @@ impl AuthnBackend for Context {
|
|||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user = UserQuery(&self.db)
|
||||
.by_username(&creds.username)
|
||||
.await?
|
||||
.filter(|u| password::verify(&u.password_hash, &creds.password));
|
||||
Ok(user)
|
||||
let mut db = self.db_pool.get()?;
|
||||
let user = users::q::by_username(&mut db, &creds.username)?;
|
||||
|
||||
if password::verify(&user.password_hash, &creds.password) {
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
UserQuery(&self.db).by_id(*user_id).await
|
||||
let mut db = self.db_pool.get()?;
|
||||
let user = users::q::by_id(&mut db, user_id)?;
|
||||
|
||||
Ok(Some(user))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
use minijinja::{path_loader, Environment};
|
||||
use free_icons::IconAttrs;
|
||||
use minijinja::{path_loader, Environment, Error, ErrorKind};
|
||||
use minijinja_autoreload::AutoReloader;
|
||||
|
||||
pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
|
||||
let reloader = AutoReloader::new(move |notifier| {
|
||||
let mut env = Environment::new();
|
||||
setup_filters(&mut env);
|
||||
|
||||
let templates_path = "templates/";
|
||||
env.set_loader(path_loader(templates_path));
|
||||
if auto_reload {
|
||||
|
@ -13,3 +16,16 @@ pub fn make_template_loader(auto_reload: bool) -> AutoReloader {
|
|||
});
|
||||
reloader
|
||||
}
|
||||
|
||||
pub fn setup_filters(env: &mut Environment) {
|
||||
env.add_filter("heroicon", heroicon_filter);
|
||||
}
|
||||
|
||||
pub fn heroicon_filter(name: String, classes: Option<String>) -> Result<String, Error> {
|
||||
let class = classes.unwrap_or_else(|| "".to_owned());
|
||||
|
||||
let attrs = IconAttrs::default().class(&class).fill("none").stroke_color("currentColor");
|
||||
|
||||
free_icons::heroicons(&name, true, attrs)
|
||||
.ok_or(Error::new(ErrorKind::TemplateNotFound, "cannot find template for requested icon"))
|
||||
}
|
||||
|
|
13
src/validation.rs
Normal file
13
src/validation.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ValidationError {
|
||||
pub field: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ValidationError {
|
||||
pub fn on(field: &str, message: &str) -> ValidationError {
|
||||
ValidationError { field: field.to_owned(), message: message.to_owned() }
|
||||
}
|
||||
}
|
1889
static/main.css
1889
static/main.css
File diff suppressed because it is too large
Load diff
|
@ -1,46 +0,0 @@
|
|||
let mobile_open_button = document.getElementById("mobile-menu-open-button");
|
||||
let mobile_close_button = document.getElementById("mobile-menu-close-button");
|
||||
let mobile_menu = document.getElementById("mobile-menu");
|
||||
|
||||
const profile_button = document.getElementById("profile-menu-button");
|
||||
let profile_dropdown = document.getElementById("profile-dropdown");
|
||||
|
||||
function toggle_mobile_menu() {
|
||||
|
||||
if (mobile_open_button.classList.contains("block")) {
|
||||
mobile_menu.classList.remove("hidden");
|
||||
mobile_open_button.classList.remove("block");
|
||||
mobile_open_button.classList.add("hidden");
|
||||
mobile_close_button.classList.remove("hidden");
|
||||
mobile_close_button.classList.add("block");
|
||||
} else {
|
||||
mobile_menu.classList.add("hidden");
|
||||
mobile_close_button.classList.remove("block");
|
||||
mobile_close_button.classList.add("hidden");
|
||||
mobile_open_button.classList.remove("hidden");
|
||||
mobile_open_button.classList.add("block");
|
||||
}
|
||||
}
|
||||
|
||||
function toggle_profile_dropdown() {
|
||||
|
||||
if (profile_dropdown.classList.contains("hidden")) {
|
||||
profile_dropdown.classList.remove("hidden");
|
||||
} else {
|
||||
profile_dropdown.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function hide_profile_dropdown() {
|
||||
let profile_dropdown = document.getElementById("profile-dropdown");
|
||||
|
||||
if (!profile_dropdown.classList.contains("hidden")) {
|
||||
profile_dropdown.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!profile_button.contains(event.target) && !profile_dropdown.contains(event.target)) {
|
||||
profile_dropdown.classList.add("hidden");
|
||||
}
|
||||
});
|
|
@ -1,9 +1,41 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./templates/**/*.html"],
|
||||
content: ["./templates/**/*.html", "./frontend/**/*.ts"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('daisyui'),
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
"pique": {
|
||||
"primary": "#F5A9B8",
|
||||
"primary-content": "#150a0c",
|
||||
"secondary": "#5BCEFA",
|
||||
"secondary-content": "#030f15",
|
||||
"accent": "#A2E4B8",
|
||||
"accent-content": "#0a120c",
|
||||
"neutral": "#ff00ff",
|
||||
"neutral-content": "#160016",
|
||||
"base-100": "#ffffff",
|
||||
"base-200": "#dedede",
|
||||
"base-300": "#bebebe",
|
||||
"base-content": "#161616",
|
||||
"info": "#4f46e5",
|
||||
"info-content": "#d6ddfe",
|
||||
"success": "#0f766e",
|
||||
"success-content": "#d3e3e0",
|
||||
"warning": "#eab308",
|
||||
"warning-content": "#130c00",
|
||||
"error": "#ef4444",
|
||||
"error-content": "#140202",
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
17
templates/components/nav/main_item.html
Normal file
17
templates/components/nav/main_item.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<li>
|
||||
{% if selected %}
|
||||
<a href="{{ path }}" class="bg-gray-100 text-gray-900 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
|
||||
<svg class="h-6 w-6 shrink-0 text-gray-900" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="{{ svg }}" />
|
||||
</svg>
|
||||
{{ text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ path }}" class="text-gray-700 hover:text-gray-900 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
|
||||
<svg class="h-6 w-6 shrink-0 text-gray-400 group-hover:text-gray-900" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="{{ svg }}" />
|
||||
</svg>
|
||||
{{ text }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
76
templates/components/sidebar.html
Normal file
76
templates/components/sidebar.html
Normal file
|
@ -0,0 +1,76 @@
|
|||
|
||||
<div class="fixed inset-y-0 z-50 flex w-72 flex-col">
|
||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-20 bg-white px-6">
|
||||
|
||||
<div class="flex h-16 shrink-0 items-center">
|
||||
<span class="h-8 w-auto text-xl">⛰️</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
|
||||
<li>
|
||||
<ul role="list" class="-mx-2 space-y-1">
|
||||
|
||||
{% with path = "/", text = "Dashboard", selected = (current_page == "home"), svg = "M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" %}
|
||||
{% include "components/nav/main_item.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% with path = "/projects", text = "Projects", selected = (current_page == "projects"), svg = "M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" %}
|
||||
{% include "components/nav/main_item.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% with path = "/documents", text = "Documents", selected = (current_page == "documents"), svg = "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" %}
|
||||
{% include "components/nav/main_item.html" %}
|
||||
{% endwith %}
|
||||
|
||||
{% with path = "/chats", text = "Chats", selected = (current_page == "chats"), svg = "M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" %}
|
||||
{% include "components/nav/main_item.html" %}
|
||||
{% endwith %}
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div class="text-xs font-semibold leading-6 text-gray-400">Your projects</div>
|
||||
<ul role="list" class="-mx-2 mt-2 space-y-1">
|
||||
|
||||
{% for project in projects %}
|
||||
<li>
|
||||
<!-- Current: "bg-gray-50 text-indigo-600", Default: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50" -->
|
||||
<a href="#" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-50 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
|
||||
<span class="flex h-6 w-10 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">{{ project.key }}</span>
|
||||
<span class="truncate">{{ project.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<div class="text-gray-500 p-2 text-xs leading-6 italic">
|
||||
No projects.
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="-mx-6 mt-auto">
|
||||
<div class="w-auto flex justify-around items-center text-sm font-semibold leading-6 text-gray-900">
|
||||
<a href="/profile" class="flex items-center gap-x-4 px-6 py-3 hover:bg-gray-50 rounded-md">
|
||||
<div class="h-8 w-8 rounded-full bg-gray-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span class="sr-only">Your profile</span>
|
||||
<span aria-hidden="true" class="">Profile</span>
|
||||
</a>
|
||||
|
||||
<a href="/logout" class="flex px-6 py-3 hover:bg-gray-50 rounded-md">
|
||||
<span>Log out</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
</div>
|
44
templates/documents/create_document.html
Normal file
44
templates/documents/create_document.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="h-full">
|
||||
{% set current_page = "projects" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="pl-72 bg-gray-50 h-full">
|
||||
<div class="navbar bg-accent text-accent-content">
|
||||
<div class="navbar-start">
|
||||
<a class="btn" href="/projects">
|
||||
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/documents/new" method="POST">
|
||||
<div class="px-8 py-8 flex flex-col gap-y-4">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
Title
|
||||
<input type="text" id="title" name="title" class="grow" placeholder="The title of the document is..." />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
Project
|
||||
<select id="project_id" name="project_id" class="grow">
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
37
templates/documents/edit_document.html
Normal file
37
templates/documents/edit_document.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="h-full">
|
||||
{% set current_page = "documents" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="pl-72 bg-gray-50 h-full">
|
||||
<form action="/documents/edit/{{ document.id }}" method="POST" class="h-full flex flex-col">
|
||||
<div class="py-1 px-1 gap-x-1 flex flex-row justify-center align-middle bg-accent text-accent-content">
|
||||
<input type="text" id="title" name="title" class="grow font-bold" value="{{ document.title }}" />
|
||||
<a class="btn btn-sm my-auto" href="/documents">
|
||||
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit
|
||||
</a>
|
||||
<button class="btn btn-sm my-auto">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div id="editor-{{ document.id }}" name="editor" class="w-full h-full">
|
||||
<noscript>
|
||||
<textarea id="content-{{ document.id }}" name="content" class="w-full h-full">{{ document.content }}</textarea>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.makeEditor("#editor-{{document.id}}", {{ document.content | tojson }});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
45
templates/documents/list_documents.html
Normal file
45
templates/documents/list_documents.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="h-full">
|
||||
{% set current_page = "documents" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="pl-72 bg-gray-50 h-full">
|
||||
<div class="navbar bg-accent text-accent-content">
|
||||
<div class="navbar-start">
|
||||
<a class="btn" href="/documents/new">New Document</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-8 flex flex-col gap-y-4">
|
||||
|
||||
|
||||
{% for document in documents %}
|
||||
<div class="card w-96 bg-base-100 shadow-md border-2 border-solid">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ document.title }}</h2>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/documents/view/{{ document.id }}" class="btn btn-primary">View</a>
|
||||
<a href="/documents/edit/{{ document.id }}" class="btn btn-primary">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
<div id="editor" class="prose bg-white"></div>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
33
templates/documents/view_document.html
Normal file
33
templates/documents/view_document.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="h-full">
|
||||
{% set current_page = "documents" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="pl-72 bg-gray-50 h-full">
|
||||
<form action="" method="POST" class="h-full flex flex-col">
|
||||
<div class="py-1 px-1 gap-x-1 flex flex-row justify-center align-middle bg-accent text-accent-content">
|
||||
<h1 class="grow text-2xl font-bold">{{ document.title }}</h1>
|
||||
<a class="btn btn-sm my-auto" href="/documents">
|
||||
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit
|
||||
</a>
|
||||
<a class="btn btn-sm my-auto" href="/documents/edit/{{ document.id }}">Edit</a>
|
||||
</div>
|
||||
|
||||
<div class="py-8 px-4 h-full prose prose-md">
|
||||
{{ rendered_document|safe }}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.makeEditor("#editor-{{document.id}}", {{ document.content | tojson }});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -3,6 +3,6 @@
|
|||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pique</title>
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👀</text></svg>"/>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛰️</text></svg>"/>
|
||||
</head>
|
||||
|
|
|
@ -2,115 +2,17 @@
|
|||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
<!--
|
||||
This example requires updating your template:
|
||||
|
||||
```
|
||||
<html class="h-full bg-gray-100">
|
||||
<body class="h-full">
|
||||
```
|
||||
-->
|
||||
<div class="min-h-full">
|
||||
<nav class="bg-purple-200">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
👀
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="ml-10 flex items-baseline space-x-4">
|
||||
<!-- Current: "bg-purple-700 text-white", Default: "text-gray-700 hover:bg-purple-500 hover:bg-opacity-75" -->
|
||||
<a href="#" class="bg-purple-700 text-white rounded-md px-3 py-2 text-sm font-medium" aria-current="page">Dashboard</a>
|
||||
<a href="#" class="text-gray-700 hover:text-white hover:bg-purple-500 hover:bg-opacity-75 rounded-md px-3 py-2 text-sm font-medium">Placeholder</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="ml-4 flex items-center md:ml-6">
|
||||
<!-- Profile dropdown -->
|
||||
<div class="relative ml-3">
|
||||
<div>
|
||||
<button type="button" class="relative flex max-w-xs items-center rounded-full bg-purple-600 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-purple-600" id="profile-menu-button" aria-expanded="false" aria-haspopup="true" onClick="toggle_profile_dropdown()">
|
||||
<span class="absolute -inset-1.5"></span>
|
||||
<span class="sr-only">Open user menu</span>
|
||||
|
||||
<!-- placeholder icon -->
|
||||
<span class="inline-block h-6 w-6 overflow-hidden rounded-full bg-gray-200">
|
||||
<svg class="h-full w-full text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="user-menu-button" tabindex="-1" id="profile-dropdown">
|
||||
|
||||
<div class="ml-3">
|
||||
<div class="text-base font-medium text-gray-600">{{ user.full_name }}</div>
|
||||
<div class="text-sm font-medium text-gray-400">{{ user.email }}</div>
|
||||
</div>
|
||||
<!-- Active: "bg-gray-100", Not Active: "" -->
|
||||
<a href="/logout" class="block px-4 py-2 text-sm text-gray-700" role="menuitem" tabindex="-1" id="user-menu-item-2">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="-mr-2 flex md:hidden">
|
||||
<!-- Mobile menu button -->
|
||||
<button type="button" class="relative inline-flex items-center justify-center rounded-md bg-purple-600 p-2 text-purple-200 hover:bg-purple-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-purple-600" aria-controls="mobile-menu" aria-expanded="false" onClick="toggle_mobile_menu()">
|
||||
<span class="absolute -inset-0.5"></span>
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<!-- Menu open: "hidden", Menu closed: "block" -->
|
||||
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" id="mobile-menu-open-button">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
<!-- Menu open: "block", Menu closed: "hidden" -->
|
||||
<svg class="hidden h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" id="mobile-menu-close-button">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu, show/hide based on menu state. -->
|
||||
<div class="md:hidden hidden" id="mobile-menu">
|
||||
<div class="space-y-1 px-2 pb-3 pt-2 sm:px-3">
|
||||
<!-- Current: "bg-purple-700 text-white", Default: "text-white hover:bg-purple-500 hover:bg-opacity-75" -->
|
||||
<a href="#" class="bg-purple-700 text-white block rounded-md px-3 py-2 text-base font-medium" aria-current="page">Dashboard</a>
|
||||
<a href="#" class="text-gray-700 hover:text-white hover:bg-purple-500 hover:bg-opacity-75 block rounded-md px-3 py-2 text-base font-medium">Placeholder</a>
|
||||
</div>
|
||||
<div class="border-t border-purple-700 pb-3 pt-4">
|
||||
<div class="flex items-center px-5">
|
||||
<div class="flex-shrink-0">
|
||||
<!-- placeholder icon -->
|
||||
<span class="inline-block h-6 w-6 overflow-hidden rounded-full bg-gray-200">
|
||||
<svg class="h-full w-full text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="text-base font-medium text-gray-600">{{ user.full_name }}</div>
|
||||
<div class="text-sm font-medium text-gray-400">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 px-2">
|
||||
<a href="/logout" class="block rounded-md px-3 py-2 text-base font-medium text-gray-700 hover:bg-purple-500 hover:bg-opacity-75">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
|
||||
|
||||
Hi there!
|
||||
{% set current_page = "home" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="py-10 pl-72">
|
||||
<div class="px-8">
|
||||
Main content.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
</body>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-purple-100">
|
||||
<html lang="en" class="h-full bg-secondary">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">Sign in to Pique</h2>
|
||||
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-secondary-content">Sign in to Pique</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
|
||||
|
@ -14,19 +14,19 @@
|
|||
<div>
|
||||
<label for="username" class="block text-sm font-medium leading-6 text-gray-900">Username</label>
|
||||
<div class="">
|
||||
<input id="username" name="username" type="username" autocomplete="username" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6">
|
||||
<input id="username" name="username" type="username" autocomplete="username" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-accent sm:text-sm sm:leading-6">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
|
||||
<div class="">
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6">
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-accent sm:text-sm sm:leading-6">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600">Sign in</button>
|
||||
<button type="submit" class="btn btn-primary w-full">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
45
templates/projects/create_project.html
Normal file
45
templates/projects/create_project.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="h-full">
|
||||
{% set current_page = "projects" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="pl-72 bg-gray-50 h-full">
|
||||
<div class="navbar bg-accent text-accent-content">
|
||||
<div class="navbar-start">
|
||||
<a class="btn" href="/projects">
|
||||
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/projects/new" method="POST">
|
||||
<div class="px-8 py-8 flex flex-col gap-y-4">
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
Name
|
||||
<input type="text" id="name" name="name" class="grow" placeholder="My project is called..." />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
Key
|
||||
<input type="text" id="key" name="key" class="grow" placeholder="Short display code, like BUG" />
|
||||
</label>
|
||||
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
Description
|
||||
<input type="text" id="description" name="description" class="grow" placeholder="What it's all about" />
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
45
templates/projects/list_projects.html
Normal file
45
templates/projects/list_projects.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-white">
|
||||
{% include "head.html" %}
|
||||
<body class="h-full">
|
||||
|
||||
<div class="h-full">
|
||||
{% set current_page = "projects" %}
|
||||
{% include "components/sidebar.html" %}
|
||||
|
||||
<main class="pl-72 bg-gray-50 h-full">
|
||||
<div class="navbar bg-accent text-accent-content">
|
||||
<div class="navbar-start">
|
||||
<a class="btn" href="/projects/new">New Project</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-8 flex flex-col gap-y-4">
|
||||
|
||||
|
||||
{% for project in projects %}
|
||||
<div class="card w-96 bg-base-100 shadow-md border-2 border-solid">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ project.key }} - {{ project.name }}</h2>
|
||||
<p>{{ project.description }}</p>
|
||||
<div class="card-actions justify-end">
|
||||
<button class="btn btn-primary">View</button>
|
||||
<button class="btn">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in a new issue