Create project and documents #1
39 changed files with 39596 additions and 2103 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
target/
|
||||
kvdata/
|
||||
node_modules/
|
||||
*.db
|
||||
*.xml
|
||||
|
|
959
Cargo.lock
generated
959
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -13,14 +13,20 @@ async-trait = "0.1.78"
|
|||
axum = "0.7.4"
|
||||
axum-htmx = { version = "0.5.0", features = ["guards", "serde"] }
|
||||
axum-login = "0.14.0"
|
||||
bincode = "1.3.3"
|
||||
clap = { version = "4.5.3", features = ["derive", "env"] }
|
||||
dotenvy = "0.15.7"
|
||||
env_logger = "0.11.3"
|
||||
minijinja = { version = "1.0.14", features = ["loader"] }
|
||||
fjall = "0.6.5"
|
||||
free-icons = "0.7.0"
|
||||
minijinja = { version = "1.0.14", features = ["loader", "json", "builtins"] }
|
||||
minijinja-autoreload = "1.0.14"
|
||||
model_derive = { path = "./model_derive" }
|
||||
rand = "0.8.5"
|
||||
redb = "2.1.0"
|
||||
sea-orm = { version = "0.12.15", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
|
||||
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"] }
|
||||
|
@ -29,3 +35,4 @@ tower-sessions-moka-store = "0.11.0"
|
|||
tower-sessions-sqlx-store = { version = "0.11.0", 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"] }
|
||||
|
|
11
Makefile
11
Makefile
|
@ -2,11 +2,20 @@
|
|||
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
|
||||
|
||||
typescript:
|
||||
npm run build
|
||||
|
||||
typescript-watch:
|
||||
npm run build-watch
|
||||
|
||||
migrate:
|
||||
sea-orm-cli migrate
|
||||
|
|
60
frontend/main.css
Normal file
60
frontend/main.css
Normal file
|
@ -0,0 +1,60 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
|
||||
|
||||
/* Borrowed from https://github.com/Milkdown/milkdown/blob/main/e2e/src/list-item-block/style.css
|
||||
which is licensed under MIT. */
|
||||
|
||||
.prose :where(li):not(:where([class~="not-prose"] *)) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose :where(blockquote):not(:where([class~="not-prose"] *)) {
|
||||
font-style: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose ol,
|
||||
.prose ul {
|
||||
list-style: none !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prose li p {
|
||||
@apply !m-0 !leading-6;
|
||||
}
|
||||
|
||||
.prose li p + p {
|
||||
@apply !mt-2;
|
||||
}
|
||||
|
||||
.prose li.ProseMirror-selectednode {
|
||||
outline: 2px solid #8cf;
|
||||
}
|
||||
|
||||
.prose li::after {
|
||||
all: unset !important;
|
||||
}
|
||||
|
||||
milkdown-list-item-block .list-item {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
milkdown-list-item-block .label-wrapper {
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: darkcyan;
|
||||
}
|
||||
|
||||
/* End borrowed block. */
|
52
frontend/main.ts
Normal file
52
frontend/main.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { defaultValueCtx, Editor, rootCtx } from '@milkdown/core';
|
||||
import { html } from 'atomico';
|
||||
import { listItemBlockComponent, listItemBlockConfig, ListItemBlockConfig, listItemBlockView } from '@milkdown/components/list-item-block'
|
||||
import { commonmark } from '@milkdown/preset-commonmark';
|
||||
import { gfm } from '@milkdown/preset-gfm';
|
||||
import { nord } from '@milkdown/theme-nord'
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener';
|
||||
import '@milkdown/theme-nord/style.css'
|
||||
|
||||
function configureListItem(ctx: Ctx) {
|
||||
ctx.set(listItemBlockConfig.key, {
|
||||
renderLabel: (label: string, listType, checked?: boolean) => {
|
||||
if (checked == null) {
|
||||
if (listType === 'bullet') {
|
||||
return html`<span class='label'>•</span>`
|
||||
}
|
||||
|
||||
return html`<span class='label'>${label}</span>`
|
||||
} else {
|
||||
return html`<input class='label' type="checkbox" checked=${checked} />`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createEditor(rootId, fieldId, content) {
|
||||
Editor
|
||||
.make()
|
||||
.config(ctx => {
|
||||
ctx.set(rootCtx, rootId)
|
||||
ctx.set(defaultValueCtx, content)
|
||||
|
||||
const listener = ctx.get(listenerCtx);
|
||||
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
|
||||
if (markdown !== prevMarkdown) {
|
||||
console.log(markdown);
|
||||
console.log(fieldId);
|
||||
document.getElementById(fieldId).value = markdown;
|
||||
console.log("updated");
|
||||
}
|
||||
})
|
||||
})
|
||||
.config(configureListItem)
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(nord)
|
||||
.use(listener)
|
||||
.use(listItemBlockComponent)
|
||||
.create();
|
||||
}
|
||||
|
||||
window.createEditor = createEditor;
|
46
model_derive/Cargo.lock
generated
Normal file
46
model_derive/Cargo.lock
generated
Normal file
|
@ -0,0 +1,46 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "model_derive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.82"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c993ed8ccba56ae856363b1845da7266a7cb78e1d146c8a32d54b45a8b831fc9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
11
model_derive/Cargo.toml
Normal file
11
model_derive/Cargo.toml
Normal file
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "model_derive"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.36"
|
||||
syn = { version = "2.0.61", features = ["full", "derive"] }
|
48
model_derive/src/lib.rs
Normal file
48
model_derive/src/lib.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{parse_macro_input, DeriveInput, LitInt};
|
||||
|
||||
#[proc_macro_derive(Model, attributes(model_version))]
|
||||
pub fn model(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
|
||||
let name = input.ident;
|
||||
let attrs = input.attrs;
|
||||
|
||||
let model_version = attrs.iter().find_map(|attr| {
|
||||
if attr.path().is_ident("model_version") {
|
||||
attr.parse_args::<LitInt>().ok().and_then(|lit| {
|
||||
Some(lit.base10_parse::<u64>().unwrap())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).unwrap_or(0);
|
||||
|
||||
let lower_name = name.to_string().to_lowercase();
|
||||
let lower_name_ident = format_ident!("{}", lower_name);
|
||||
|
||||
|
||||
let expanded = quote! {
|
||||
impl Model for #name {
|
||||
type Id = uuid::Uuid;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn key(id: Self::Id) -> Vec<u8> {
|
||||
let mut key = vec![];
|
||||
key.extend_from_slice(format!("{}:{}:", #lower_name, #model_version).as_bytes());
|
||||
key.extend_from_slice(&id.into_bytes());
|
||||
key
|
||||
}
|
||||
|
||||
fn partition(kv_handle: &KvHandle) -> &PartitionHandle {
|
||||
&kv_handle.#lower_name_ident
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
}
|
2668
package-lock.json
generated
2668
package-lock.json
generated
File diff suppressed because it is too large
Load diff
23
package.json
23
package.json
|
@ -1,5 +1,26 @@
|
|||
{
|
||||
"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",
|
||||
"build-watch": "esbuild frontend/main.ts --bundle --outfile=static/main.js --target=es2017 --watch",
|
||||
"css-watch": "tailwindcss -i ./frontend/main.css -o ./static/style.css --watch",
|
||||
"css": "tailwindcss -i ./frontend/main.css -o ./static/style.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@milkdown/components": "^7.3.6",
|
||||
"@milkdown/core": "^7.3.6",
|
||||
"@milkdown/plugin-listener": "^7.3.6",
|
||||
"@milkdown/preset-commonmark": "^7.3.6",
|
||||
"@milkdown/preset-gfm": "^7.3.6",
|
||||
"@milkdown/theme-nord": "^7.3.6",
|
||||
"@prosemirror-adapter/lit": "^0.2.6",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwindcss": "^3.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,18 +2,21 @@ use std::sync::Arc;
|
|||
|
||||
use minijinja_autoreload::AutoReloader;
|
||||
|
||||
use crate::{handler::internal_server_error, prelude::*};
|
||||
use crate::{handler::internal_server_error, kv::KvHandle, prelude::*};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
pub db: DatabaseConnection,
|
||||
// TODO: add a design doc explaining why this not relational
|
||||
pub kv_handles: KvHandle,
|
||||
template_loader: Arc<AutoReloader>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(db: DatabaseConnection, template_loader: AutoReloader) -> Context {
|
||||
pub fn new(db: DatabaseConnection, kv_handles: KvHandle, template_loader: AutoReloader) -> Context {
|
||||
Context {
|
||||
db,
|
||||
kv_handles,
|
||||
template_loader: Arc::new(template_loader),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
pub mod documents;
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod projects;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
|
|
170
src/handler/documents.rs
Normal file
170
src/handler/documents.rs
Normal file
|
@ -0,0 +1,170 @@
|
|||
use axum::{extract::Path, response::Redirect, Form};
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use crate::{handler::internal_server_error, models::{Document, ModelPermission, ModelType, Permission}, prelude::*};
|
||||
|
||||
pub async fn documents_page(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
) -> Response {
|
||||
if let Some(user) = auth_session.user {
|
||||
render_documents_page(ctx, user).await
|
||||
} else {
|
||||
Redirect::to("/login").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_documents_page(ctx: Context, user: crate::entity::user::Model) -> Response {
|
||||
let documents = ModelPermission::user_documents(&ctx.kv_handles, user.id).unwrap_or_default();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
documents => documents,
|
||||
};
|
||||
|
||||
ctx.render_resp("documents/list_documents.html", values)
|
||||
}
|
||||
|
||||
pub async fn create_document_page(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
) -> Response {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
projects => projects,
|
||||
};
|
||||
ctx.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(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
form: Form<CreateDocumentSubmission>,
|
||||
) -> Response {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let project = match ModelPermission::user_project(&ctx.kv_handles, user.id, form.project_id) {
|
||||
Ok(Some(project)) => project,
|
||||
Ok(None) => return Redirect::to("/documents/create").into_response(),
|
||||
Err(err) => {
|
||||
error!(?err, "failed to access kv store");
|
||||
return Redirect::to("/documents/create").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let document = Document {
|
||||
id: Uuid::now_v7(),
|
||||
project_id: project.id,
|
||||
title: form.title.to_owned(),
|
||||
content: "".to_owned(),
|
||||
};
|
||||
|
||||
if let Err(err) = document.save(&ctx.kv_handles) {
|
||||
error!(?err, "failed to save document");
|
||||
return internal_server_error();
|
||||
}
|
||||
info!(?document, "document created");
|
||||
|
||||
let permission = ModelPermission {
|
||||
user_id: user.id,
|
||||
model_type: ModelType::Document,
|
||||
role: Permission::Admin,
|
||||
model_id: document.id,
|
||||
};
|
||||
|
||||
if let Err(err) = permission.add(&ctx.kv_handles) {
|
||||
error!(?err, "failed to save new project permission");
|
||||
return internal_server_error();
|
||||
}
|
||||
|
||||
|
||||
|
||||
Redirect::to("/documents").into_response()
|
||||
}
|
||||
|
||||
pub async fn edit_document_page(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
Path((id,)): Path<(Uuid,)>,
|
||||
) -> Response {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let document = match ModelPermission::user_document(&ctx.kv_handles, user.id, id) {
|
||||
Ok(Some(document)) => document,
|
||||
Ok(None) => return Redirect::to("/documents").into_response(),
|
||||
Err(err) => {
|
||||
error!(?err, "failed to load document");
|
||||
return internal_server_error();
|
||||
}
|
||||
};
|
||||
|
||||
dbg!(&document);
|
||||
let values = context! {
|
||||
user => user,
|
||||
document => document,
|
||||
};
|
||||
|
||||
ctx.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(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
Path((document_id,)): Path<(Uuid,)>,
|
||||
form: Form<EditDocumentSubmission>,
|
||||
) -> Response {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let mut document = match ModelPermission::user_document(&ctx.kv_handles, user.id, document_id) {
|
||||
Ok(Some(document)) => document,
|
||||
Ok(None) => return Redirect::to("/documents").into_response(),
|
||||
Err(err) => {
|
||||
error!(?err, "failed to load document");
|
||||
return internal_server_error();
|
||||
}
|
||||
};
|
||||
|
||||
let new_document = Document {
|
||||
id: document.id,
|
||||
project_id: document.id,
|
||||
title: form.title.to_owned(),
|
||||
content: form.content.to_owned(),
|
||||
};
|
||||
|
||||
if let Err(err) = new_document.save(&ctx.kv_handles) {
|
||||
error!(?err, "failed to save document");
|
||||
return internal_server_error();
|
||||
}
|
||||
info!(?new_document, "document updated");
|
||||
|
||||
Redirect::to("/documents").into_response()
|
||||
}
|
|
@ -1,12 +1,15 @@
|
|||
use axum::response::Redirect;
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::{models::{ModelPermission, Project}, prelude::*};
|
||||
|
||||
pub async fn home_page(State(ctx): State<Context>, auth_session: AuthSession<Context>) -> Response {
|
||||
if let Some(user) = auth_session.user {
|
||||
let projects: Vec<Project> = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
projects => projects,
|
||||
};
|
||||
|
||||
ctx.render_resp("home.html", values)
|
||||
|
|
92
src/handler/projects.rs
Normal file
92
src/handler/projects.rs
Normal file
|
@ -0,0 +1,92 @@
|
|||
use axum::{response::Redirect, Form};
|
||||
use axum_login::AuthSession;
|
||||
|
||||
use crate::{
|
||||
handler::internal_server_error,
|
||||
models::{ModelPermission, ModelType, Permission, Project},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
pub async fn projects_page(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
) -> Response {
|
||||
if let Some(user) = auth_session.user {
|
||||
render_projects_page(ctx, user).await
|
||||
} else {
|
||||
Redirect::to("/login").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn render_projects_page(ctx: Context, user: crate::entity::user::Model) -> Response {
|
||||
let projects = ModelPermission::user_projects(&ctx.kv_handles, user.id).unwrap_or_default();
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
projects => projects,
|
||||
};
|
||||
|
||||
ctx.render_resp("projects/list_projects.html", values)
|
||||
}
|
||||
|
||||
pub async fn create_project_page(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
) -> Response {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let values = context! {
|
||||
user => user,
|
||||
};
|
||||
ctx.render_resp("projects/create_project.html", values)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProjectSubmission {
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub async fn create_project_submit(
|
||||
State(ctx): State<Context>,
|
||||
auth_session: AuthSession<Context>,
|
||||
form: Form<CreateProjectSubmission>,
|
||||
) -> Response {
|
||||
let user = match auth_session.user {
|
||||
Some(user) => user,
|
||||
None => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
|
||||
let project = Project {
|
||||
id: Uuid::now_v7(),
|
||||
owner_id: user.id,
|
||||
|
||||
name: form.name.clone(),
|
||||
key: form.key.clone(),
|
||||
description: form.description.clone(),
|
||||
};
|
||||
// TODO: validation
|
||||
|
||||
if let Err(err) = project.save(&ctx.kv_handles) {
|
||||
error!(?err, "failed to save new project");
|
||||
return internal_server_error();
|
||||
}
|
||||
|
||||
let permission = ModelPermission {
|
||||
user_id: user.id,
|
||||
model_type: ModelType::Project,
|
||||
role: Permission::Admin,
|
||||
model_id: project.id,
|
||||
};
|
||||
|
||||
if let Err(err) = permission.add(&ctx.kv_handles) {
|
||||
error!(?err, "failed to save new project permission");
|
||||
return internal_server_error();
|
||||
}
|
||||
|
||||
Redirect::to("/projects").into_response()
|
||||
}
|
42
src/kv.rs
Normal file
42
src/kv.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use fjall::{Config, Keyspace, PartitionCreateOptions, PartitionHandle};
|
||||
|
||||
/// Contains the handles needed to reference key-value data.
|
||||
///
|
||||
/// This contains both the Keyspace and multiple PartitionHandle.
|
||||
/// The Keyspace allows operational control and reporting at the top level,
|
||||
/// while each PartitionHandle controls reading, writing, and removing from a
|
||||
/// particular partition of the data.
|
||||
///
|
||||
/// All fields are public, because this is meant to be used internally as a
|
||||
/// wrapper to pass everything around, instead of passing each handle around by
|
||||
/// itself.
|
||||
#[derive(Clone)]
|
||||
pub struct KvHandle {
|
||||
pub keyspace: Keyspace,
|
||||
|
||||
pub project: PartitionHandle,
|
||||
pub document: PartitionHandle,
|
||||
pub permissions: PartitionHandle,
|
||||
}
|
||||
|
||||
impl KvHandle {
|
||||
pub fn open<P: AsRef<Path>>(p: P) -> Result<KvHandle> {
|
||||
// TODO: those should probably be configurable, or like, not just hard coded.
|
||||
let config = Config::new(p).flush_workers(4).compaction_workers(4);
|
||||
let keyspace = Keyspace::open(config)?;
|
||||
|
||||
let project = keyspace.open_partition("project", PartitionCreateOptions::default())?;
|
||||
let document = keyspace.open_partition("document", PartitionCreateOptions::default())?;
|
||||
let permissions = keyspace.open_partition("permissions", PartitionCreateOptions::default())?;
|
||||
|
||||
Ok(KvHandle {
|
||||
keyspace,
|
||||
project,
|
||||
document,
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -4,8 +4,11 @@ pub mod db;
|
|||
pub mod entity;
|
||||
pub mod handler;
|
||||
pub mod logging;
|
||||
pub mod models;
|
||||
pub mod password;
|
||||
pub mod prelude;
|
||||
pub mod serialize;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod templates;
|
||||
pub mod kv;
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
pub fn setup_logging() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
190
src/models.rs
Normal file
190
src/models.rs
Normal file
|
@ -0,0 +1,190 @@
|
|||
use core::fmt::{self, Display};
|
||||
use crate::prelude::*;
|
||||
|
||||
use anyhow::Result;
|
||||
use fjall::PartitionHandle;
|
||||
use model_derive::Model;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::kv::KvHandle;
|
||||
|
||||
pub trait Model: Sized + Serialize + for<'a> Deserialize<'a> {
|
||||
type Id;
|
||||
|
||||
fn id(&self) -> Self::Id;
|
||||
fn key(id: Self::Id) -> Vec<u8>;
|
||||
fn partition(kv_handle: &KvHandle) -> &PartitionHandle;
|
||||
|
||||
fn save(&self, kv_handle: &KvHandle) -> Result<()> {
|
||||
let key = Self::key(self.id());
|
||||
let value = bincode::serialize(self)?;
|
||||
let partition = Self::partition(kv_handle);
|
||||
|
||||
partition.insert(key, value)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(kv_handle: &KvHandle, id: Self::Id) -> Result<Option<Self>> {
|
||||
let key = Self::key(id);
|
||||
let partition = Self::partition(kv_handle);
|
||||
|
||||
match partition.get(key.as_slice())? {
|
||||
Some(bytes) => {
|
||||
let bytes = bytes.to_vec();
|
||||
let value: Self = bincode::deserialize(&bytes)?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Model, Serialize, Deserialize, PartialEq)]
|
||||
#[model_version(0)]
|
||||
pub struct Project {
|
||||
pub id: Uuid,
|
||||
pub owner_id: i32,
|
||||
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
|
||||
// The key is the short code, like BUG, which is used to refer to a project
|
||||
// quickly and to display it more compactly. This must be unique across the
|
||||
// projects a user owns.
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Model, Serialize, Deserialize, PartialEq)]
|
||||
#[model_version(0)]
|
||||
pub struct Document {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum ModelType {
|
||||
Project = 0,
|
||||
Document = 1,
|
||||
}
|
||||
|
||||
impl Display for ModelType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ModelType::Project => write!(f, "project"),
|
||||
ModelType::Document => write!(f, "document"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum Permission {
|
||||
Admin = 0,
|
||||
Read = 1,
|
||||
}
|
||||
|
||||
impl Display for Permission {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Permission::Admin => write!(f, "admin"),
|
||||
Permission::Read => write!(f, "read"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelPermission {
|
||||
pub user_id: i32,
|
||||
pub model_type: ModelType,
|
||||
pub role: Permission,
|
||||
pub model_id: Uuid,
|
||||
}
|
||||
|
||||
impl ModelPermission {
|
||||
pub fn add(&self, kv_handle: &KvHandle) -> Result<()> {
|
||||
let key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
self.user_id, self.model_type, self.role, self.model_id
|
||||
);
|
||||
let value = bincode::serialize(self)?;
|
||||
|
||||
kv_handle.permissions.insert(key, value)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn user_projects(kv_handle: &KvHandle, user_id: i32) -> Result<Vec<Project>> {
|
||||
let prefix = format!("{}:{}:", user_id, ModelType::Project);
|
||||
|
||||
let mut ids = vec![];
|
||||
for row in kv_handle.permissions.prefix(prefix).into_iter() {
|
||||
let (_key, value) = row?;
|
||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
||||
ids.push(permission.model_id);
|
||||
}
|
||||
|
||||
let projects: Vec<Project> = ids
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let res = Project::load(kv_handle, id);
|
||||
res.ok().flatten()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
pub fn user_project(kv_handle: &KvHandle, user_id: i32, project_id: Uuid) -> Result<Option<Project>> {
|
||||
let key = format!("{}:{}:{}:{}", user_id, ModelType::Project, Permission::Admin, project_id);
|
||||
let value = kv_handle.permissions.get(key)?;
|
||||
|
||||
match value {
|
||||
Some(value) => {
|
||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
||||
let project = Project::load(kv_handle, permission.model_id)?;
|
||||
Ok(project)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_documents(kv_handle: &KvHandle, user_id: i32) -> Result<Vec<Document>> {
|
||||
let prefix = format!("{}:{}:", user_id, ModelType::Document);
|
||||
|
||||
let mut ids = vec![];
|
||||
for row in kv_handle.permissions.prefix(prefix).into_iter() {
|
||||
let (_key, value) = row?;
|
||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
||||
ids.push(permission.model_id);
|
||||
}
|
||||
dbg!(&ids);
|
||||
|
||||
let documents: Vec<Document> = ids
|
||||
.into_iter()
|
||||
.filter_map(|id| Document::load(kv_handle, id).ok().flatten())
|
||||
.collect();
|
||||
dbg!(&documents);
|
||||
|
||||
Ok(documents)
|
||||
}
|
||||
|
||||
pub fn user_document(kv_handle: &KvHandle, user_id: i32, document_id: Uuid) -> Result<Option<Document>> {
|
||||
let key = format!("{}:{}:{}:{}", user_id, ModelType::Document, Permission::Admin, document_id);
|
||||
let value = kv_handle.permissions.get(key)?;
|
||||
|
||||
match value {
|
||||
Some(value) => {
|
||||
let permission: ModelPermission = bincode::deserialize(&value)?;
|
||||
let document = Document::load(kv_handle, permission.model_id)?;
|
||||
Ok(document)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
pub use crate::context::Context;
|
||||
pub use crate::entity::prelude::*;
|
||||
pub use crate::models::Model;
|
||||
pub use axum::extract::State;
|
||||
pub use axum::response::{Html, IntoResponse, Response};
|
||||
pub use minijinja::context;
|
||||
|
|
16
src/serialize.rs
Normal file
16
src/serialize.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
use bincode::{DefaultOptions, Options};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn bincode_options() -> impl Options {
|
||||
DefaultOptions::new().with_big_endian()
|
||||
}
|
||||
|
||||
pub fn serialize<T: ?Sized + Serialize>(value: &T) -> Result<Vec<u8>, bincode::Error> {
|
||||
let options = bincode_options();
|
||||
options.serialize(value)
|
||||
}
|
||||
|
||||
pub fn deserialize<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result<T, bincode::Error> {
|
||||
let options = bincode_options();
|
||||
options.deserialize(bytes)
|
||||
}
|
|
@ -10,7 +10,7 @@ use tower_sessions::SessionManagerLayer;
|
|||
use tower_sessions_sqlx_store::{sqlx::SqlitePool, 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, context::Context, handler::{documents::{create_document_page, create_document_submit, documents_page, edit_document_page, edit_document_submit}, home::home_page, login::logout, login_page, login_submit, projects::{create_project_page, create_project_submit, projects_page}}, kv::KvHandle, logging::setup_logging, templates::make_template_loader};
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
dotenvy::dotenv()?;
|
||||
|
@ -25,7 +25,10 @@ pub async fn run() -> Result<()> {
|
|||
|
||||
let session_layer = create_session_manager_layer().await?;
|
||||
|
||||
let context = Context::new(db, template_loader);
|
||||
// TODO: better name, also make it an option
|
||||
let kv_handles = KvHandle::open("./kvdata/")?;
|
||||
|
||||
let context = Context::new(db, kv_handles, template_loader);
|
||||
|
||||
let auth_backend = context.clone();
|
||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer.clone()).build();
|
||||
|
@ -40,6 +43,14 @@ 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/edit/:id", get(edit_document_page))
|
||||
.route("/documents/edit/:id", post(edit_document_submit))
|
||||
.layer(trace_layer)
|
||||
.layer(session_layer)
|
||||
.layer(auth_layer)
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
use minijinja::{Error, ErrorKind};
|
||||
use free_icons::IconAttrs;
|
||||
use minijinja::{path_loader, Environment};
|
||||
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 +17,18 @@ 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"))
|
||||
}
|
||||
|
|
2037
static/main.css
2037
static/main.css
File diff suppressed because it is too large
Load diff
31878
static/main.js
31878
static/main.js
File diff suppressed because it is too large
Load diff
2844
static/style.css
Normal file
2844
static/style.css
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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>
|
48
templates/documents/edit_document.html
Normal file
48
templates/documents/edit_document.html
Normal file
|
@ -0,0 +1,48 @@
|
|||
<!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">
|
||||
<div class="navbar bg-accent text-accent-content">
|
||||
<div class="navbar-start gap-2">
|
||||
<a class="btn" href="/documents">
|
||||
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
|
||||
</a>
|
||||
<button class="btn" onClick="saveDocument()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" value="{{ document.title }}" />
|
||||
</label>
|
||||
|
||||
<div id="editor-{{document.id}}" class="prose bg-white"></div>
|
||||
|
||||
<textarea id="content-{{ document.id }}" name="content" class="hidden">
|
||||
</textarea>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="text/javascript" src="/static/main.js"></script>
|
||||
<script type="text/javascript">
|
||||
console.log("hi");
|
||||
window.createEditor("#editor-{{document.id}}", "content-{{document.id}}", {{document.content | tojson }});
|
||||
console.log({{document.content | tojson}});
|
||||
console.log("hi2");
|
||||
function saveDocument() {
|
||||
console.log("saving");
|
||||
}
|
||||
</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/edit/{{ document.id }}" class="btn btn-primary">Open</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>
|
|
@ -3,6 +3,7 @@
|
|||
<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/style.css">
|
||||
<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="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!
|
||||
<div>
|
||||
{% 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