This commit is contained in:
Nicole Tietz-Sokolskaya 2024-05-16 22:30:38 -04:00
parent b04fcd6204
commit 5eead5fd3c
7 changed files with 260 additions and 6 deletions

View file

@ -1,7 +1,7 @@
use axum::{response::Redirect, Form}; use axum::{extract::Path, response::Redirect, Form};
use axum_login::AuthSession; use axum_login::AuthSession;
use crate::{models::ModelPermission, prelude::*}; use crate::{handler::internal_server_error, models::{Document, ModelPermission, ModelType, Permission}, prelude::*};
pub async fn documents_page( pub async fn documents_page(
State(ctx): State<Context>, State(ctx): State<Context>,
@ -24,3 +24,103 @@ async fn render_documents_page(ctx: Context, user: crate::entity::user::Model) -
ctx.render_resp("documents/list_documents.html", values) 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 {
project_id: Uuid,
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();
}
};
let values = context! {
user => user,
document => document,
};
ctx.render_resp("documents/edit_document.html", values)
}

View file

@ -62,7 +62,7 @@ pub async fn create_project_submit(
}; };
let project = Project { let project = Project {
id: Uuid::new_v4(), id: Uuid::now_v7(),
owner_id: user.id, owner_id: user.id,
name: form.name.clone(), name: form.name.clone(),

View file

@ -140,6 +140,20 @@ impl ModelPermission {
Ok(projects) 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>> { pub fn user_documents(kv_handle: &KvHandle, user_id: i32) -> Result<Vec<Document>> {
let prefix = format!("{}:{}:", user_id, ModelType::Document); let prefix = format!("{}:{}:", user_id, ModelType::Document);
@ -149,13 +163,28 @@ impl ModelPermission {
let permission: ModelPermission = bincode::deserialize(&value)?; let permission: ModelPermission = bincode::deserialize(&value)?;
ids.push(permission.model_id); ids.push(permission.model_id);
} }
dbg!(&ids);
let documents: Vec<Document> = ids let documents: Vec<Document> = ids
.into_iter() .into_iter()
.filter_map(|id| Document::load(kv_handle, id).ok().flatten()) .filter_map(|id| Document::load(kv_handle, id).ok().flatten())
.collect(); .collect();
dbg!(&documents);
Ok(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

@ -10,7 +10,7 @@ use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore}; use tower_sessions_sqlx_store::{sqlx::SqlitePool, SqliteStore};
use tracing::Level; use tracing::Level;
use crate::{config::CommandLineOptions, context::Context, handler::{documents::documents_page, 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}; use crate::{config::CommandLineOptions, context::Context, handler::{documents::{create_document_page, create_document_submit, documents_page, edit_document_page}, 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<()> { pub async fn run() -> Result<()> {
dotenvy::dotenv()?; dotenvy::dotenv()?;
@ -47,6 +47,9 @@ pub async fn run() -> Result<()> {
.route("/projects/new", get(create_project_page)) .route("/projects/new", get(create_project_page))
.route("/projects/new", post(create_project_submit)) .route("/projects/new", post(create_project_submit))
.route("/documents", get(documents_page)) .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))
.layer(trace_layer) .layer(trace_layer)
.layer(session_layer) .layer(session_layer)
.layer(auth_layer) .layer(auth_layer)

View file

@ -1132,6 +1132,40 @@ select {
justify-content: flex-end; justify-content: flex-end;
} }
.select {
display: inline-flex;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
height: 3rem;
min-height: 3rem;
padding-left: 1rem;
padding-right: 2.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
line-height: 2;
border-radius: var(--rounded-btn, 0.5rem);
border-width: 1px;
border-color: transparent;
--tw-bg-opacity: 1;
background-color: var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 20px) calc(1px + 50%),
calc(100% - 16.1px) calc(1px + 50%);
background-size: 4px 4px,
4px 4px;
background-repeat: no-repeat;
}
.select[multiple] {
height: auto;
}
.btm-nav > * .label { .btm-nav > * .label {
font-size: 1rem; font-size: 1rem;
line-height: 1.5rem; line-height: 1.5rem;
@ -1519,6 +1553,50 @@ select {
} }
} }
.select:focus {
box-shadow: none;
border-color: var(--fallback-bc,oklch(var(--bc)/0.2));
outline-style: solid;
outline-width: 2px;
outline-offset: 2px;
outline-color: var(--fallback-bc,oklch(var(--bc)/0.2));
}
.select-disabled,
.select:disabled,
.select[disabled] {
cursor: not-allowed;
--tw-border-opacity: 1;
border-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));
--tw-bg-opacity: 1;
background-color: var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));
color: var(--fallback-bc,oklch(var(--bc)/0.4));
}
.select-disabled::-moz-placeholder, .select:disabled::-moz-placeholder, .select[disabled]::-moz-placeholder {
color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));
--tw-placeholder-opacity: 0.2;
}
.select-disabled::placeholder,
.select:disabled::placeholder,
.select[disabled]::placeholder {
color: var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));
--tw-placeholder-opacity: 0.2;
}
.select-multiple,
.select[multiple],
.select[size].select:not([size="1"]) {
background-image: none;
padding-right: 1rem;
}
[dir="rtl"] .select {
background-position: calc(0% + 12px) calc(1px + 50%),
calc(0% + 16px) calc(1px + 50%);
}
@keyframes skeleton { @keyframes skeleton {
from { from {
background-position: 150%; background-position: 150%;

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

@ -22,14 +22,13 @@
</div> </div>
<div class="px-8 py-8 flex flex-col gap-y-4"> <div class="px-8 py-8 flex flex-col gap-y-4">
<div id="editor" class="prose bg-white"></div>
{% for document in documents %} {% for document in documents %}
<div class="card w-96 bg-base-100 shadow-md border-2 border-solid"> <div class="card w-96 bg-base-100 shadow-md border-2 border-solid">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ document.name }}</h2> <h2 class="card-title">{{ document.name }}</h2>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<button class="btn btn-primary">Open</button> <a href="/documents/edit/{{ document.id }}" class="btn btn-primary">Open</a>
</div> </div>
</div> </div>
@ -37,6 +36,7 @@
{% endfor %} {% endfor %}
</div> </div>
<div id="editor" class="prose bg-white"></div>
</main> </main>