diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 5056fbd..f9493cb 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -10,6 +10,7 @@ argon2.workspace = true pbkdf2.workspace = true rand_core.workspace = true serde.workspace = true +serde_json.workspace = true sqlx.workspace = true time.workspace = true tracing.workspace = true diff --git a/crates/model/migrations/014_upload_tags.sql b/crates/model/migrations/014_upload_tags.sql new file mode 100644 index 0000000..dc87510 --- /dev/null +++ b/crates/model/migrations/014_upload_tags.sql @@ -0,0 +1,19 @@ +-- Adds tags to uploads. Tags are just short strings, and uploads can have multiple tags. + +CREATE TABLE tags ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + user TEXT, + team TEXT, + UNIQUE (name, user), + UNIQUE (name, team), + CHECK ((user IS NOT NULL AND team IS NULL) OR (user IS NULL AND team IS NOT NULL)), + FOREIGN KEY (user) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (team) REFERENCES teams (id) ON DELETE CASCADE +); + +CREATE TABLE upload_tags ( + upload TEXT NOT NULL REFERENCES uploads (id) ON DELETE CASCADE, + tag TEXT NOT NULL REFERENCES tags (id) ON DELETE CASCADE, + PRIMARY KEY (upload, tag) +); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 5a71c61..840b799 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -1,5 +1,6 @@ pub mod migration; pub mod password; +pub mod tag; pub mod team; pub mod types; pub mod upload; diff --git a/crates/model/src/tag.rs b/crates/model/src/tag.rs new file mode 100644 index 0000000..fd75aee --- /dev/null +++ b/crates/model/src/tag.rs @@ -0,0 +1,125 @@ +use serde::Serialize; +use sqlx::{FromRow, SqlitePool}; + +use super::{team::Team, types::Key, upload::UploadOwner, user::User}; + +#[derive(Debug, FromRow, Serialize)] +pub struct Tag { + pub id: Key, + pub name: String, + pub user: Option>, + pub team: Option>, +} + +impl Tag { + pub fn new_for_user(name: String, user: Key) -> Self { + Self { + id: Key::new(), + name, + user: Some(user), + team: None, + } + } + + pub fn new_for_team(name: String, team: Key) -> Self { + Self { + id: Key::new(), + name, + user: None, + team: Some(team), + } + } + + pub async fn create(&self, pool: &SqlitePool) -> sqlx::Result<()> { + let result = sqlx::query("INSERT INTO tags (id, name, user, team) VALUES ($1, $2, $3, $4)") + .bind(self.id) + .bind(&self.name) + .bind(self.user) + .bind(self.team) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(sqlx::Error::RowNotFound); + } + + Ok(()) + } + + pub async fn get_for_user(pool: &SqlitePool, user_id: Key) -> sqlx::Result> { + sqlx::query_as("SELECT * FROM tags WHERE user = $1 ORDER BY name") + .bind(user_id) + .fetch_all(pool) + .await + } + + pub async fn get_for_user_by_name( + pool: &SqlitePool, + user_id: Key, + name: &str, + ) -> sqlx::Result> { + sqlx::query_as("SELECT * FROM tags WHERE user = $1 AND name = $2") + .bind(user_id) + .bind(name) + .fetch_optional(pool) + .await + } + + pub async fn get_or_create_for_user( + pool: &SqlitePool, + user_id: Key, + name: &str, + ) -> sqlx::Result { + if let Some(tag) = Self::get_for_user_by_name(pool, user_id, name).await? { + return Ok(tag); + } + + let tag = Self::new_for_user(name.to_string(), user_id); + tag.create(pool).await?; + Ok(tag) + } + + pub async fn get_for_team(pool: &SqlitePool, team_id: Key) -> sqlx::Result> { + sqlx::query_as("SELECT * FROM tags WHERE team = $1 ORDER BY name") + .bind(team_id) + .fetch_all(pool) + .await + } + + pub async fn get_for_team_by_name( + pool: &SqlitePool, + team_id: Key, + name: &str, + ) -> sqlx::Result> { + sqlx::query_as("SELECT * FROM tags WHERE team = $1 AND name = $2") + .bind(team_id) + .bind(name) + .fetch_optional(pool) + .await + } + + pub async fn get_or_create_for_team( + pool: &SqlitePool, + team_id: Key, + name: &str, + ) -> sqlx::Result { + if let Some(tag) = Self::get_for_team_by_name(pool, team_id, name).await? { + return Ok(tag); + } + + let tag = Self::new_for_team(name.to_string(), team_id); + tag.create(pool).await?; + Ok(tag) + } + + pub async fn get_or_create_for_owner( + pool: &SqlitePool, + owner: UploadOwner, + name: &str, + ) -> sqlx::Result { + match owner { + UploadOwner::User(user_id) => Self::get_or_create_for_user(pool, user_id, name).await, + UploadOwner::Team(team_id) => Self::get_or_create_for_team(pool, team_id, name).await, + } + } +} diff --git a/crates/model/src/upload.rs b/crates/model/src/upload.rs index 40248ae..bc3f91b 100644 --- a/crates/model/src/upload.rs +++ b/crates/model/src/upload.rs @@ -5,6 +5,7 @@ use time::{Date, OffsetDateTime}; use super::{ password::StoredPassword, + tag::Tag, team::{Team, TeamMember}, types::Key, user::User, @@ -78,7 +79,25 @@ pub enum UploadOwnership { OwnedByTeam(TeamMember), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadOwner { + User(Key), + Team(Key), +} + impl Upload { + pub fn get_owner(&self) -> Option { + if let Some(owner_user) = self.owner_user { + return Some(UploadOwner::User(owner_user)); + } + + if let Some(owner_team) = self.owner_team { + return Some(UploadOwner::Team(owner_team)); + } + + None + } + pub async fn create(&self, pool: &SqlitePool) -> sqlx::Result<()> { sqlx::query( "INSERT INTO uploads (id, slug, filename, size, public, @@ -545,6 +564,39 @@ impl Upload { } } } + + pub async fn get_tags(&self, pool: &SqlitePool) -> sqlx::Result> { + sqlx::query_scalar( + "SELECT tags.name FROM upload_tags + LEFT JOIN tags ON tags.id = upload_tags.tag + WHERE upload_tags.upload = $1 + ORDER BY tags.name", + ) + .bind(self.id) + .fetch_all(pool) + .await + } + + pub async fn replace_tags(&self, pool: &SqlitePool, tags: Vec>) -> sqlx::Result<()> { + // First, delete all existing tags for this upload. + sqlx::query("DELETE FROM upload_tags WHERE upload = $1") + .bind(self.id) + .execute(pool) + .await?; + + // Then, insert the new tags. + sqlx::query( + "INSERT INTO upload_tags (upload, tag) \ + SELECT $1, value \ + FROM json_each($2);", + ) + .bind(self.id) + .bind(serde_json::to_string(&tags).expect("JSON serialization of key array")) + .execute(pool) + .await?; + + Ok(()) + } } #[derive(Debug, FromRow, Serialize)] @@ -608,6 +660,7 @@ pub struct UploadList { pub uploaded_by_id: Option>, pub uploaded_by_name: Option, pub uploaded_at: OffsetDateTime, + pub tags: String, } impl UploadList { @@ -625,15 +678,24 @@ impl UploadList { uploads.size, uploads.public, uploads.downloads, \ uploads.\"limit\", uploads.remaining, uploads.expiry_date, \ uploads.custom_slug, \ - uploads.password is not null as has_password, \ + uploads.password IS NOT NULL AS has_password, \ COALESCE(teams.slug, users.username) AS owner_slug, \ uploads.uploaded_by AS uploaded_by_id, \ uploader.name AS uploaded_by_name, \ - uploads.uploaded_at \ + uploads.uploaded_at, \ + tags.tags \ FROM uploads \ LEFT JOIN teams ON uploads.owner_team = teams.id \ LEFT JOIN users ON uploads.owner_user = users.id \ LEFT JOIN users AS uploader ON uploads.uploaded_by = uploader.id \ + LEFT JOIN (\ + SELECT \ + upload_tags.upload, \ + GROUP_CONCAT(tags.name, ',' ORDER BY tags.name) AS tags \ + FROM upload_tags \ + LEFT JOIN tags ON tags.id = upload_tags.tag \ + GROUP BY upload_tags.upload \ + ) AS tags ON uploads.id = tags.upload \ WHERE uploads.owner_user = $1 {} \ ORDER BY {} {} LIMIT {} OFFSET {}", if let Some(search) = search { @@ -669,11 +731,20 @@ impl UploadList { COALESCE(teams.slug, users.username) AS owner_slug, \ uploads.uploaded_by AS uploaded_by_id, \ uploader.name AS uploaded_by_name, \ - uploads.uploaded_at \ + uploads.uploaded_at, \ + tags.tags \ FROM uploads \ LEFT JOIN teams ON uploads.owner_team = teams.id \ LEFT JOIN users ON uploads.owner_user = users.id \ LEFT JOIN users AS uploader ON uploads.uploaded_by = uploader.id \ + LEFT JOIN (\ + SELECT \ + upload_tags.upload, \ + GROUP_CONCAT(tags.name, ',' ORDER BY tags.name) AS tags \ + FROM upload_tags \ + LEFT JOIN tags ON tags.id = upload_tags.tag \ + GROUP BY upload_tags.upload \ + ) AS tags ON uploads.id = tags.upload \ WHERE uploads.owner_team = $1 {} \ ORDER BY {} {} LIMIT {} OFFSET {}", if let Some(search) = search { diff --git a/crates/server/scripts/components/select.ts b/crates/server/scripts/components/select.ts index 8ecbc40..65e11c4 100644 --- a/crates/server/scripts/components/select.ts +++ b/crates/server/scripts/components/select.ts @@ -83,7 +83,7 @@ const WithState: FunctionComponent<{ selector?: string }> = ({ }) => { const [state, dispatch] = useReducer( reduceState, - createInitialState(selector), + createInitialState(selector) ); return html` <${StateContext.Provider} value=${{ state, dispatch }}> @@ -144,12 +144,12 @@ const ParcelSelectDropdown: FunctionComponent<{ name: string }> = ({ }; const optionElements = options.map( - (option) => html`<${ParcelSelectOption} name=${name} option=${option} />`, + (option) => html`<${ParcelSelectOption} name=${name} option=${option} />` ); return html`
-
+
Select all = ({ `; }; -const ParcelSelectInfo: FunctionComponent<{ placeholder?: string }> = ({ - placeholder, -}) => { +const ParcelSelectInfo: FunctionComponent<{ + noun?: string; + placeholder?: string; +}> = ({ noun, placeholder }) => { const options = useContext(OptionsContext); const { state } = useContext(StateContext); + const nounSingular = noun ? noun : "option"; + const nounPlural = noun ? `${noun}s` : "options"; + const names = options .filter((option) => state.checked.has(option.value)) - .map((option) => option.label) - .join(", "); + .map((option) => html`${option.label}`); return html` -
+
${state.checked.size == 0 - ? placeholder || "No selection" - : `${state.checked.size} selected:`} - ${names} + ${names}
`; }; const ParcelSelect: FunctionComponent<{ name: string; + noun?: string; class?: string; placeholder?: string; }> = (props) => { @@ -222,7 +228,10 @@ const ParcelSelect: FunctionComponent<{ class="parcel-select ${props.class} ${state.open ? "open" : ""}" onclick=${onOuterClick} > - <${ParcelSelectInfo} placeholder=${props.placeholder} /> + <${ParcelSelectInfo} + noun=${props.noun} + placeholder=${props.placeholder} + /> <${ParcelSelectDropdown} name=${props.name} />
`; @@ -234,11 +243,12 @@ const ParcelSelectOuter: FunctionComponent<{ values?: string; class?: string; placeholder?: string; + noun?: string; }> = (props) => { return html` <${WithOptions} selector=${props.options}> <${WithState} selector=${props.values}> - <${ParcelSelect} name=${props.name} class=${props.class} placeholder=${props.placeholder} /> + <${ParcelSelect} name=${props.name} noun=${props.noun} class=${props.class} placeholder=${props.placeholder} /> `; @@ -248,6 +258,8 @@ export function register() { registerElement(ParcelSelectOuter, "parcel-select", [ "name", "class", + "noun", "options", + "values", ]); } diff --git a/crates/server/scripts/components/tag-input.ts b/crates/server/scripts/components/tag-input.ts new file mode 100644 index 0000000..967aa29 --- /dev/null +++ b/crates/server/scripts/components/tag-input.ts @@ -0,0 +1,162 @@ +const VALID_TAG_RE = /^[\p{L}\p{N}_ -]+$/u; + +class ParcelTagInput extends HTMLElement { + #tags: string[] = []; + #tagContainer: HTMLElement; + #tagInput: HTMLInputElement; + #addButton: HTMLButtonElement; + #internals = this.attachInternals(); + + static formAssociated = true; + + connectedCallback() { + this.classList.add("tag-input"); + + this.#tags = this.#getTags(); + this.#updateFormValue(); + + this.#tagContainer = document.createElement("div"); + this.#tagContainer.classList.add("tag-container"); + + this.#tags.forEach((tag, index) => { + const tagElement = this.#createTagElement(tag, index); + this.#tagContainer.appendChild(tagElement); + }); + + const inputContainer = document.createElement("div"); + inputContainer.classList.add("input-container"); + + this.#creatAddButton(); + this.#createTagInput(); + inputContainer.append(this.#tagInput, this.#addButton); + this.append(this.#tagContainer, inputContainer); + } + + #createTagElement(tag: string, index: number): HTMLElement { + const tagElement = document.createElement("div"); + tagElement.classList.add("tag", "large"); + + const nameElement = document.createElement("span"); + nameElement.classList.add("name"); + nameElement.textContent = tag; + + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("icon-x"); + button.addEventListener("click", () => { + this.#tags.splice(index, 1); + tagElement.remove(); + this.#updateFormValue(); + }); + + tagElement.append(nameElement, button); + return tagElement; + } + + #createTagInput() { + this.#tagInput = document.createElement("input"); + this.#tagInput.type = "text"; + this.#tagInput.placeholder = "Add a tag, press Enter"; + // this.#tagInput.pattern = VALID_TAG_RE.source; + this.#tagInput.classList.add("field", "grow"); + + const datalist = this.querySelector("datalist"); + this.#tagInput.setAttribute("list", datalist ? datalist.id : ""); + + this.#tagInput.addEventListener("input", () => { + this.#validateInput(); + }); + + this.#tagInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + + if (this.#isValidInput()) { + this.#addTag(); + } + } else { + this.#validateInput(); + } + }); + } + + #creatAddButton() { + this.#addButton = document.createElement("button"); + this.#addButton.type = "button"; + this.#addButton.classList.add("button"); + this.#addButton.title = "Add tag"; + this.#addButton.disabled = true; + this.#addButton.addEventListener("click", () => { + if (this.#isValidInput()) { + this.#addTag(); + } + }); + + const addIcon = document.createElement("span"); + addIcon.classList.add("icon-plus"); + + const addText = document.createTextNode(" Add"); + this.#addButton.append(addIcon, addText); + } + + #getInputTag(): string { + return this.#tagInput.value.trim().replace(/\s+/g, " "); + } + + #isValidInput() { + const tag = this.#getInputTag(); + if (tag.length === 0) { + return false; + } + + if (!VALID_TAG_RE.test(tag)) { + return false; + } + + return true; + } + + #validateInput() { + const valid = this.#isValidInput(); + this.#tagInput.classList.toggle("invalid", !valid); + this.#addButton.disabled = !valid; + } + + #addTag() { + if (!this.#isValidInput()) { + return; + } + + const tag = this.#getInputTag(); + this.#tags.push(tag); + const tagElement = this.#createTagElement(tag, this.#tags.length - 1); + this.#tagContainer.appendChild(tagElement); + this.#tagInput.value = ""; + this.#tagInput.classList.remove("invalid"); + this.#addButton.disabled = true; + this.#updateFormValue(); + } + + #getTags() { + const value = this.getAttribute("value") || ""; + return value + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag); + } + + #updateFormValue() { + const name = this.getAttribute("name") || "tags"; + + const formData = new FormData(); + this.#tags.forEach((tag) => { + formData.append(name, tag); + }); + + this.#internals.setFormValue(formData); + } +} + +export function register() { + customElements.define("parcel-tag-input", ParcelTagInput); +} diff --git a/crates/server/scripts/init.ts b/crates/server/scripts/init.ts index d7bdbaa..8f526d4 100644 --- a/crates/server/scripts/init.ts +++ b/crates/server/scripts/init.ts @@ -2,10 +2,11 @@ import { register as register_baseurl } from "./components/baseurl"; import { register as register_checklist } from "./components/checklist"; import { register as register_clipboard } from "./components/clipboard"; import { register as register_date } from "./components/date"; +import { register as register_dropdown } from "./components/dropdown"; import { register as register_modal } from "./components/modal"; import { register as register_select } from "./components/select"; import { register as register_teams } from "./components/teams"; -import { register as register_dropdown } from "./components/dropdown"; +import { register as register_tag_input } from "./components/tag-input"; function init() { if (window.customElements) { @@ -19,10 +20,11 @@ function init() { register_checklist(); register_clipboard(); register_date(); + register_dropdown(); register_modal(); register_select(); register_teams(); - register_dropdown(); + register_tag_input(); document.addEventListener("click", (event) => { if ((event.target as HTMLElement).closest(".dismiss-skip")) { diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 30529d2..2f08a43 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -92,6 +92,8 @@ pub fn create_app( "/uploads/:id/public" handlers::uploads::public POST "/uploads/:id/reset" handlers::uploads::reset POST "/uploads/:id/share" handlers::uploads::share GET + "/uploads/:id/tags" handlers::uploads::tags GET + "/uploads/:id/tags/edit" handlers::uploads::tags_edit GET POST "/uploads/:id/transfer" handlers::uploads::transfer GET POST "/uploads/:owner/:slug" handlers::uploads::custom_upload GET "/teams/:id" handlers::teams::team GET diff --git a/crates/server/src/app/handlers/index.rs b/crates/server/src/app/handlers/index.rs index 80f0aaf..6658ec6 100644 --- a/crates/server/src/app/handlers/index.rs +++ b/crates/server/src/app/handlers/index.rs @@ -6,6 +6,7 @@ use poem::{ }; use parcel_model::{ + tag::Tag, team::{HomeTab, TeamTab}, upload::{UploadList, UploadStats}, }; @@ -65,6 +66,12 @@ pub async fn get_index( poem::error::InternalServerError(err) })?; + // Get the tags for the user. + let tags = Tag::get_for_user(&env.pool, user.id).await.map_err(|err| { + tracing::error!(%user.id, ?err, "Unable to get tags for user"); + poem::error::InternalServerError(err) + })?; + render_template( "index.html", minijinja::context! { @@ -72,6 +79,7 @@ pub async fn get_index( home, tabs, stats, + tags, uploads, csrf_token => csrf_token.0, page => 0, diff --git a/crates/server/src/app/handlers/uploads.rs b/crates/server/src/app/handlers/uploads.rs index 0416145..4eacb47 100644 --- a/crates/server/src/app/handlers/uploads.rs +++ b/crates/server/src/app/handlers/uploads.rs @@ -25,6 +25,7 @@ mod download; mod edit; mod list; mod new; +mod tags; mod transfer; mod upload; @@ -32,6 +33,7 @@ pub use download::{get_download, post_download}; pub use edit::{get_edit, post_check_slug, post_edit}; pub use list::{get_list, get_page, post_delete, ListQuery}; pub use new::{get_new, post_new}; +pub use tags::{get_tags, get_tags_edit, post_tags_edit}; pub use transfer::{get_transfer, post_transfer}; pub use upload::{ delete_preview_error, delete_upload, get_custom_upload, get_preview, get_share, get_upload, diff --git a/crates/server/src/app/handlers/uploads/tags.rs b/crates/server/src/app/handlers/uploads/tags.rs new file mode 100644 index 0000000..8b647ee --- /dev/null +++ b/crates/server/src/app/handlers/uploads/tags.rs @@ -0,0 +1,180 @@ +use minijinja::context; +use poem::{ + error::InternalServerError, + http::StatusCode, + web::{CsrfToken, CsrfVerifier, Data, Form, Html, Path}, +}; + +use parcel_model::{ + tag::Tag, + types::Key, + upload::{Upload, UploadPermission}, +}; + +use crate::{ + app::{ + errors::CsrfError, + extractors::user::SessionUser, + handlers::utils::{check_permission, get_upload_by_id, has_permission}, + templates::{authorized_context, render_template}, + }, + env::Env, +}; + +#[poem::handler] +pub async fn get_tags( + env: Data<&Env>, + SessionUser(user): SessionUser, + Path(id): Path>, +) -> poem::Result> { + let upload = get_upload_by_id(&env, id).await?; + check_permission(&env, &upload, Some(&user), UploadPermission::View).await?; + let editable = has_permission(&env, &upload, Some(&user), UploadPermission::Edit).await?; + + let tags = upload.get_tags(&env.pool).await.map_err(|err| { + tracing::error!(?err, %id, "Failed to get tags for upload"); + InternalServerError(err) + })?; + + render_template( + "uploads/tags/tags.html", + context! { + upload, + tags, + editable, + ..authorized_context(&env, &user) + }, + ) + .await +} + +#[poem::handler] +pub async fn get_tags_edit( + env: Data<&Env>, + csrf_token: &CsrfToken, + SessionUser(user): SessionUser, + Path(id): Path>, +) -> poem::Result> { + let upload = get_upload_by_id(&env, id).await?; + check_permission(&env, &upload, Some(&user), UploadPermission::Edit).await?; + + let tags = upload.get_tags(&env.pool).await.map_err(|err| { + tracing::error!(?err, %id, "Failed to get tags for upload"); + InternalServerError(err) + })?; + + let available = if let Some(owner_team) = upload.owner_team { + Tag::get_for_team(&env.pool, owner_team) + .await + .map_err(|err| { + tracing::error!(?err, %id, %owner_team, "Failed to get team owner of upload"); + InternalServerError(err) + })? + } else if let Some(owner_user) = upload.owner_user { + Tag::get_for_user(&env.pool, owner_user) + .await + .map_err(|err| { + tracing::error!(?err, %id, "Failed to get tags for user"); + InternalServerError(err) + })? + } else { + vec![] + }; + + render_template( + "uploads/tags/edit.html", + context! { + upload, + tags, + available, + csrf_token => csrf_token.0, + ..authorized_context(&env, &user) + }, + ) + .await +} + +#[poem::handler] +pub async fn post_tags_edit( + env: Data<&Env>, + csrf_verifier: &CsrfVerifier, + SessionUser(user): SessionUser, + Path(id): Path>, + Form(form): Form>, +) -> poem::Result> { + let mut tags = Vec::new(); + let mut csrf_token = None; + + for (key, value) in form { + if key == "csrf_token" { + if csrf_token.is_some() { + tracing::error!("Duplicate CSRF token in upload tags edit"); + return Err(CsrfError.into()); + } + + csrf_token = Some(value); + } else if key == "tags" { + tags.push(value); + } else { + tracing::error!(?key, "Unexpected form field in upload tags edit"); + return Err(poem::Error::from_status(StatusCode::BAD_REQUEST)); + } + } + + let Some(csrf_token) = csrf_token else { + tracing::error!("CSRF token not found in upload tags edit"); + return Err(CsrfError.into()); + }; + + if !csrf_verifier.is_valid(&csrf_token) { + tracing::error!("CSRF token is invalid in upload tags edit"); + return Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)); + } + + let upload = get_upload_by_id(&env, id).await?; + check_permission(&env, &upload, Some(&user), UploadPermission::Edit).await?; + let Some(owner) = upload.get_owner() else { + tracing::error!(%id, "Upload has no owner (neither team nor user)"); + return Err(poem::Error::from_status(StatusCode::BAD_REQUEST)); + }; + + // Find the corresponding IDs for each of the tags + let tag_ids = { + let mut tag_ids = Vec::new(); + + for tag in &tags { + let tag = Tag::get_or_create_for_owner(&env.pool, owner, tag) + .await + .map_err(|err| { + tracing::error!(?err, %id, ?tag, "Failed to get or create tag"); + InternalServerError(err) + })?; + + tag_ids.push(tag.id); + } + + tag_ids + }; + + // Replace the tags attached to the upload. + upload + .replace_tags(&env.pool, tag_ids) + .await + .map_err(|err| { + tracing::error!(?err, %id, "Failed to replace tags for upload"); + InternalServerError(err) + })?; + + tags.sort(); + + render_template( + "uploads/tags/tags.html", + context! { + upload, + tags, + editable => true, + ..authorized_context(&env, &user) + }, + ) + .await +} diff --git a/crates/server/src/app/handlers/utils.rs b/crates/server/src/app/handlers/utils.rs index 2975956..abdaff2 100644 --- a/crates/server/src/app/handlers/utils.rs +++ b/crates/server/src/app/handlers/utils.rs @@ -34,12 +34,12 @@ pub async fn get_upload_by_slug(env: &Env, slug: &str) -> poem::Result { Ok(upload) } -pub async fn check_permission( +pub async fn has_permission( env: &Env, upload: &Upload, user: Option<&User>, permission: UploadPermission, -) -> poem::Result<()> { +) -> poem::Result { let granted = upload .can_access(&env.pool, user, permission) .await @@ -48,6 +48,17 @@ pub async fn check_permission( InternalServerError(err) })?; + Ok(granted) +} + +pub async fn check_permission( + env: &Env, + upload: &Upload, + user: Option<&User>, + permission: UploadPermission, +) -> poem::Result<()> { + let granted = has_permission(env, upload, user, permission).await?; + if !granted { let uid = user.map(|u| u.id); tracing::error!(upload = %upload.id, ?permission, user = ?uid, diff --git a/crates/server/style/components/button.css b/crates/server/style/components/button.css index 3e641c0..7cf6418 100644 --- a/crates/server/style/components/button.css +++ b/crates/server/style/components/button.css @@ -7,6 +7,7 @@ @apply focus:ring-2 focus:outline-none focus:ring-primary-300; @apply dark:focus:ring-primary-800; @apply text-center text-sm font-medium text-nowrap; + @apply border border-transparent; &:not(:disabled) { @apply hover:bg-primary-600; diff --git a/crates/server/style/components/form.css b/crates/server/style/components/form.css index 1baf415..8325722 100644 --- a/crates/server/style/components/form.css +++ b/crates/server/style/components/form.css @@ -26,7 +26,7 @@ } .field { - @apply block w-full px-2.5 py-2; + @apply block w-full px-5 py-2; @apply sm:text-sm; @apply outline-none border rounded-lg; @apply bg-gray-50 border-gray-300 text-gray-900; @@ -39,7 +39,7 @@ @apply dark:bg-gray-800 dark:border-gray-700 dark:text-gray-500; } - &:invalid { + &:invalid, &.invalid { @apply border-red-500; @apply dark:border-red-400; } diff --git a/crates/server/style/components/select.css b/crates/server/style/components/select.css index 13e7748..00b29d8 100644 --- a/crates/server/style/components/select.css +++ b/crates/server/style/components/select.css @@ -1,7 +1,7 @@ @layer components { .parcel-select { @apply relative flex flex-row gap-1 cursor-pointer; - @apply p-2.5 sm:text-sm border rounded-lg; + @apply px-5 py-2 sm:text-sm border rounded-lg; @apply bg-gray-50 border-gray-300 text-gray-900; @apply dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white; @@ -23,6 +23,21 @@ @apply flex; } } + + .parcel-selections { + @apply text-ellipsis overflow-hidden whitespace-nowrap ml-1; + + > span.option { + @apply text-primary-800 dark:text-primary-300; + + & + span.option:before { + content: ', '; + + @apply text-gray-900 dark:text-white; + } + } + + } } .parcel-select-options { diff --git a/crates/server/style/components/tag-input.css b/crates/server/style/components/tag-input.css new file mode 100644 index 0000000..871d8c2 --- /dev/null +++ b/crates/server/style/components/tag-input.css @@ -0,0 +1,31 @@ +@layer components { + .tag-input { + @apply flex flex-col gap-4; + + > .tag-container { + @apply flex flex-row gap-2 flex-wrap; + + > .tag { + @apply flex flex-row items-center gap-1 py-2 px-2.5; + @apply border border-primary-200 dark:border-primary-900; + + &.new { + @apply bg-primary-100 dark:bg-primary-800; + } + + > span.name { + @apply grow; + } + + > button { + @apply cursor-pointer; + } + } + } + + > .input-container { + @apply flex flex-row gap-2; + } + + } +} diff --git a/crates/server/style/components/tags.css b/crates/server/style/components/tags.css new file mode 100644 index 0000000..5b7a28c --- /dev/null +++ b/crates/server/style/components/tags.css @@ -0,0 +1,11 @@ +@layer components { + .tag { + @apply text-nowrap items-center px-2 py-1 text-xs font-medium rounded-full; + @apply bg-primary-200 hover:bg-primary-300 text-neutral-700 hover:text-neutral-900; + @apply dark:bg-primary-900 dark:hover:bg-primary-800 dark:text-neutral-100 dark:hover:text-neutral-50; + + &.large { + @apply text-sm; + } + } +} diff --git a/crates/server/style/main.css b/crates/server/style/main.css index 4a4d612..3867d60 100644 --- a/crates/server/style/main.css +++ b/crates/server/style/main.css @@ -13,6 +13,8 @@ @import "./components/select.css"; @import "./components/table.css"; @import "./components/tabs.css"; +@import "./components/tag-input.css"; +@import "./components/tags.css"; @import "./components/uploads.css"; @import "./utils/animation.css"; diff --git a/crates/server/templates/uploads/list.html b/crates/server/templates/uploads/list.html index 95e0ce4..46bfcd5 100644 --- a/crates/server/templates/uploads/list.html +++ b/crates/server/templates/uploads/list.html @@ -26,6 +26,13 @@ Team Settings {% endif %} + + +
@@ -28,6 +28,12 @@ {% endif %} {{ upload.filename }} + + {% with + tags = upload.tags | split(",") | list if upload.tags | length > 0 else [], + editable = (not team) or membership.can_edit %} + {% include "uploads/tags/tags.html" %} + {% endwith %}
{{ upload.size | filesizeformat }}
@@ -123,6 +129,16 @@ Edit upload … + + + Edit tags … + diff --git a/cypress/cypress/e2e/004_list.cy.js b/cypress/cypress/e2e/004_list.cy.js index fd3363f..c2822db 100644 --- a/cypress/cypress/e2e/004_list.cy.js +++ b/cypress/cypress/e2e/004_list.cy.js @@ -240,7 +240,7 @@ describe("File List", () => { ); cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='filename']") @@ -278,7 +278,7 @@ describe("File List", () => { cy.get("#uploads-table .dropdown-button").click(); cy.get("a[hx-post$='/public']").should("contain", "Make public"); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='public'") @@ -294,7 +294,7 @@ describe("File List", () => { cy.get("#uploads-table .dropdown-button").click(); cy.get("a[hx-post$='/public']").should("contain", "Make private"); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='public'") @@ -331,7 +331,7 @@ describe("File List", () => { // Set a download limit of 10 cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='limit']").should("be.disabled"); @@ -399,7 +399,7 @@ describe("File List", () => { // Change the download limit to 20 cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='limit_check']").should("be.checked"); @@ -418,7 +418,7 @@ describe("File List", () => { // Remove the download limit cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='limit_check']") @@ -456,7 +456,7 @@ describe("File List", () => { // Set a download expiry of 3 days cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); const today = new Date(); const in7Days = new Date(); @@ -481,7 +481,7 @@ describe("File List", () => { // Remove the download expiry cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']").click(); + cy.get("a[title='Edit upload settings']").click(); cy.get(".modal > .content").should("be.visible"); cy.get(".modal > .content input[name='expiry_check']") diff --git a/cypress/cypress/e2e/006_team_settings.cy.js b/cypress/cypress/e2e/006_team_settings.cy.js index 3ef8c5c..94fe731 100644 --- a/cypress/cypress/e2e/006_team_settings.cy.js +++ b/cypress/cypress/e2e/006_team_settings.cy.js @@ -146,7 +146,7 @@ describe("Teams", () => { "test-file.txt" ); cy.get("#uploads-table .dropdown-button").click(); - cy.get("a[hx-get$='/edit']") + cy.get("a[title='Edit upload settings']") .should("be.visible") .and("contain", "Edit upload") .click();