Create project and documents (#1)

This is a big dump of a lot of code, mostly making it so that:
- we have a key-value store
- we can create/save/load projects and documents
- there's a sidebar layout with some placeholders we may or may not need
- other stuff I forgot

Reviewed-on: #1
This commit is contained in:
Nicole Tietz-Sokolskaya 2024-05-21 12:59:04 +00:00
parent 5b117e9a8c
commit e0653e4bdd
39 changed files with 39596 additions and 2103 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
target/
kvdata/
node_modules/
*.db
*.xml

959
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"] }

View file

@ -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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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),
}
}

View file

@ -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
View 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()
}

View file

@ -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
View 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
View 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,
})
}
}

View file

@ -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;

View file

@ -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();
}

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

190
src/models.rs Normal file
View 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),
}
}
}

View file

@ -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
View 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)
}

View file

@ -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)

View file

@ -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"))
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

2844
static/style.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -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",
}
},
],
},
}

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>