better projects upload and mobile design

This commit is contained in:
2024-11-11 02:40:30 +01:00
parent b4ff6e6fc0
commit 9fa897be57
16 changed files with 292 additions and 27 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,14 @@
document.addEventListener('DOMContentLoaded', () => {
const carousels = document.querySelectorAll('[id^="carousel-"]');
carousels.forEach((carousel) => {
let buttons = carousel.querySelectorAll('.carousel-button');
let activeSlide = 0;
if (buttons.length === 1) {
buttons[0].classList.add('opacity-0');
}
buttons.forEach((button, index) => {
button.addEventListener('click', () => {
let currentSlide = carousel.querySelector(

View File

@@ -1,6 +1,7 @@
{% extends "website/base.html" %} {% block content %}
<div class="w-full mt-16"></div>
<form method="post" id="project-upload" class="flex flex-col gap-2 text-black" action="/api/projects">
<form method="post" id="project-upload" class="flex flex-col gap-2 text-black" action="/api/projects/upload"
enctype="multipart/form-data">
<label class="text-white" for="name">Project Name:</label>
<input type="text" id="name" name="name" required />
@@ -31,6 +32,9 @@
<label class="text-white" for="technology">Technologies:</label>
<input type="text" id="technology" name="technologies" required />
<label for="files" class="text-white">Choose thumbnails files:</label>
<input type="file" id="files" name="thumbnail" multiple accept="image/*">
<button type="submit" class="p-2 text-gray-900 bg-yellow-500 rounded-sm shadow hover:bg-yellow-600">Submit</button>
</form>
{% endblock content%}

View File

@@ -1,12 +1,13 @@
{% import "website/macros/chip.html" as chip %} {% extends "website/base.html"
%} {% block content %}
<div class="w-full">
<div class="w-full mt-16 md:hidden"></div>
<div class="relative inline-block w-full min-w-full">
<img src="/static/images/optimized-75.webp" alt="Background"
class="object-cover w-full max-h-full pointer-events-none" />
class="hidden object-cover w-full max-h-full pointer-events-none md:block" />
<div
class="absolute inset-0 flex flex-col items-center justify-center w-full gap-4 md:items-start md:justify-start md:p-16 lg:p-20">
<div>
class="flex flex-col items-center justify-start w-full md:inset-0 md:absolute md:items-start md:p-16 lg:p-20">
<div class="hidden md:block">
<h1
class="mb-4 text-2xl font-bold tracking-tight text-white md:text-4xl lg:text-6xl md:mb-0 -motion-translate-x-in-100 motion-translate-y-in-75">
Gabriel Kaszewski
@@ -16,7 +17,15 @@
Full-Stack Developer
</h2>
</div>
<div class="items-center hidden gap-2 md:flex motion-preset-slide-right motion-duration-2000">
<div class="md:hidden">
<h1 class="text-2xl font-bold tracking-tight text-white motion-preset-slide-right motion-duration-1000">
Gabriel Kaszewski
</h1>
<h2 class="text-lg font-light tracking-tight text-white motion-preset-slide-right motion-duration-1000">
Full-Stack Developer
</h2>
</div>
<div class="flex items-center gap-2 mt-4 motion-preset-slide-right motion-duration-2000 md:mt-0">
<a href="/api/data/cv.pdf" title="My CV">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -51,7 +60,7 @@
</a>
</div>
</div>
<div class="absolute bottom-0 p-2 text-sm">
<div class="absolute bottom-0 hidden p-2 text-sm md:block">
<span class="flex gap-1">
Photo by
<a class="underline"
@@ -67,7 +76,6 @@
</div>
</div>
</div>
<div class="w-full mt-8"></div>
<div id="who-am-i" class="flex flex-col items-center justify-center gap-4 p-4 rounded md:w-fit">
<h3 class="mt-4 mb-2 text-5xl font-bold tracking-tight">Who am I? 🤔</h3>
<section class="prose text-white md:prose-lg lg:prose-xl">

View File

@@ -2,14 +2,14 @@
<div id="{{ id }}" class="carousel relative shadow-lg w-full max-w-full md:max-w-[50hw] h-[40rem]">
<div class="relative w-full h-full overflow-hidden carousel-inner">
{% for thumbnail in thumbnails %}
<div class="carousel-item absolute inset-0 w-full h-full {% if forloop.first %}{% else %} opacity-0 {% endif %} transition-opacity ease-in-out delay-250 duration-500">
<div class="absolute inset-0 w-full h-full transition-opacity duration-500 ease-in-out carousel-item delay-250">
<img alt="slide" src="{{ thumbnail }}" class="object-cover w-full h-full">
</div>
{% endfor %}
</div>
<div class="absolute bottom-0 z-50 flex justify-center w-full gap-2 py-2">
{% for thumbnail in thumbnails %}
<button class="carousel-button" data-target="{{ forloop.counter0 }}"></button>
<button class="carousel-button" data-target="{{ loop.index0 }}"></button>
{% endfor %}
</div>
</div>

View File

@@ -109,7 +109,7 @@ async fn login(
let token = user
.generate_jwt(&jwt_secret.secret, &jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
.or_else(|_| unauthorized("jwt error"))?;
let cookie = Cookie::build(("token", token.clone()))
.path("/")

View File

@@ -1,6 +1,8 @@
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use axum::extract::Multipart;
use format::redirect;
use loco_rs::prelude::*;
use crate::{
@@ -39,8 +41,24 @@ async fn create_project(
format::json(&project)
}
async fn create_project_with_thumbnails(
auth: auth::JWT,
State(ctx): State<AppContext>,
payload: Multipart,
) -> Result<Response> {
match users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await {
Ok(_) => {}
Err(_) => return unauthorized("Unauthorized"),
}
services::projects::add_project_with_thumbnails_multipart(&auth, &ctx, payload).await?;
redirect("/projects")
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/projects/")
.add("/", post(create_project))
.add("/upload", post(create_project_with_thumbnails))
}

13
src/services/auth.rs Normal file
View File

@@ -0,0 +1,13 @@
use loco_rs::prelude::*;
use crate::models::users;
pub async fn is_logged_in(
auth: &auth::JWT,
ctx: &AppContext,
) -> Result<bool> {
match users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use crate::models::_entities::data::{self, ActiveModel, Entity, Model};
use crate::models::users::users;
use crate::shared::get_file_name_with_extension::get_file_name_with_extension_from_field;
use axum::extract::Multipart;
use axum_extra::headers::Range;
use axum_extra::TypedHeader;
@@ -129,10 +130,7 @@ pub async fn add(
protected = Some(value);
}
"file" => {
let og_file_name = field
.file_name()
.ok_or_else(|| ModelError::Any("Failed to get file name".into()))?;
let ext = String::from(og_file_name.split('.').last().unwrap_or("txt"));
let (og_file_name, ext) = get_file_name_with_extension_from_field(&field, "txt").map_err(|_| ModelError::Any("Failed to get file name".into()))?;
let temp_file_name = if uuid_name {
let temp_file_name = uuid::Uuid::new_v4().to_string();

View File

@@ -1,7 +1,8 @@
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use crate::{models::{
_entities::jobs::{Entity, Model},
_entities::jobs::{Column, Entity, Model},
jobs::JobWithTechnologies,
}, shared::get_technologies_from_string::get_technologies_from_string};
@@ -11,7 +12,9 @@ pub async fn get_all_jobs(ctx: &AppContext) -> Result<Vec<Model>> {
}
pub async fn get_all_jobs_with_technologies(ctx: &AppContext) -> Result<Vec<JobWithTechnologies>> {
let jobs = Entity::find().all(&ctx.db).await?;
let jobs = Entity::find()
.order_by_asc(Column::StartDate)
.all(&ctx.db).await?;
let jobs_with_technologies = jobs
.into_iter()
.map(|job| {

View File

@@ -3,3 +3,4 @@ pub mod jobs;
pub mod skills;
pub mod website;
pub mod projects;
pub mod auth;

View File

@@ -1,13 +1,15 @@
use std::path::PathBuf;
use axum::extract::Multipart;
use loco_rs::prelude::*;
use crate::{
models::{
_entities::{
project_thumbnails,
projects::{self, ActiveModel, Entity, Model},
data, project_thumbnails, projects::{self, ActiveModel, Entity, Model}
},
projects::{get_category_from_string, get_string_from_category, CreateProject, ProjectDto, UpdateProject},
}, services::data::add_data_file_from_path, shared::get_technologies_from_string::get_technologies_from_string
projects::{get_category_from_string, get_string_from_category, CreateProject, ProjectDto, UpdateProject}, users,
}, services::data::add_data_file_from_path, shared::{get_file_name_with_extension::get_file_name_with_extension_from_field, get_technologies_from_string::get_technologies_from_string}
};
use super::data::delete_data_by_id;
@@ -62,13 +64,39 @@ pub async fn get_all_projects_dto(ctx: &AppContext) -> Result<Vec<ProjectDto>> {
.all(&ctx.db)
.await?;
let thumbnails_ids = projects_with_thumbnails
.iter()
.map(|(_, thumbnails)| {
thumbnails
.iter()
.map(|thumbnail| thumbnail.data_id)
.collect::<Vec<i32>>()
})
.flatten()
.collect::<Vec<i32>>();
let thumbnails_data = data::Entity::find()
.filter(model::query::condition().is_in(data::Column::Id, thumbnails_ids).build())
.all(&ctx.db)
.await?;
let thumbnails_map = thumbnails_data
.into_iter()
.map(|thumbnail| (thumbnail.id, thumbnail))
.collect::<std::collections::HashMap<i32, data::Model>>();
let projects_dto = projects_with_thumbnails
.into_iter()
.map(|(project, thumbnails)| {
let thumbnails = thumbnails
.into_iter()
.map(|thumbnail| thumbnail.data_id.to_string())
.map(|thumbnail| {
let thumbnail_data = thumbnails_map.get(&thumbnail.data_id).unwrap();
let url = format!("/api/data/{}", thumbnail_data.file_name);
url
})
.collect();
ProjectDto {
id: project.id,
name: project.name,
@@ -189,6 +217,176 @@ pub async fn add_project_with_thumbnails(
Ok(())
}
pub async fn add_project_with_thumbnails_multipart(
auth: &auth::JWT,
ctx: &AppContext,
mut payload: Multipart,
) -> Result<()> {
let _current_user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
let mut project_name = None;
let mut short_description = None;
let mut description = None;
let mut category = None;
let mut github_url = None;
let mut download_url = None;
let mut visit_url = None;
let mut technologies = None;
let mut thumbnails_file_names = Vec::new();
let mut thumbnails = Vec::new();
while let Some(field) = payload
.next_field()
.await
.map_err(|_| ModelError::Any("Failed to get next field".into()))?
{
let name = field
.name()
.ok_or_else(|| ModelError::Any("Failed to get field name".into()))?;
match name {
"name" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
project_name = Some(value);
}
"short_description" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
short_description = Some(value);
}
"description" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
description = Some(value);
}
"category" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
category = Some(value);
}
"github_url" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
github_url = Some(value);
}
"download_url" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
download_url = Some(value);
}
"visit_url" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
visit_url = Some(value);
}
"technologies" => {
let value = field
.text()
.await
.map_err(|_| ModelError::Any("Failed to get text".into()))?;
technologies = Some(value);
}
"thumbnail" => {
let (_, ext) = get_file_name_with_extension_from_field(&field, "txt").map_err(|_| ModelError::Any("Failed to get file name".into()))?;
let file_name = uuid::Uuid::new_v4().to_string();
let file_name = format!("{}.{}", file_name, ext);
let data_content = field
.bytes()
.await
.map_err(|_| ModelError::Any("Failed to get bytes".into()))?;
thumbnails_file_names.push(file_name);
thumbnails.push(data_content);
},
_ => {}
}
}
let category = category.map(|s| get_category_from_string(&s));
let project = CreateProject {
name: project_name.ok_or_else(|| ModelError::Any("Name field is required".into()))?,
short_description: short_description.ok_or_else(|| ModelError::Any("Short description field is required".into()))?,
description: description,
category: category.ok_or_else(|| ModelError::Any("Category field is required".into()))?,
github_url: github_url,
download_url: download_url,
visit_url: visit_url,
technologies: technologies.ok_or_else(|| ModelError::Any("Technologies field is required".into()))?.split(",").map(|s| s.to_string()).collect(),
};
let txn = ctx.db.begin().await?;
let item = ActiveModel {
name: Set(project.name),
short_description: Set(project.short_description),
description: Set(project.description),
category: Set(get_string_from_category(&project.category)),
github_url: Set(project.github_url),
download_url: Set(project.download_url),
visit_url: Set(project.visit_url),
technology: Set(project.technologies.join(",")),
..Default::default()
};
let item = item.insert(&txn).await?;
let project_id = item.id;
for (thumbnail_file_name, thumbnail) in thumbnails_file_names.iter().zip(thumbnails.iter()) {
let thumbnail_data = data::ActiveModel {
file_name: Set(thumbnail_file_name.clone()),
file_url: Set(format!("uploads/{}", thumbnail_file_name)),
protected: Set(false),
..Default::default()
};
let thumbnail_data = thumbnail_data.insert(&txn).await?;
let path = PathBuf::from(thumbnail_file_name);
match ctx
.storage
.as_ref()
.upload(
path.as_path(),
thumbnail,
)
.await {
Ok(_) => {
let thumbnail = project_thumbnails::ActiveModel {
project_id: Set(project_id),
data_id: Set(thumbnail_data.id),
..Default::default()
};
thumbnail.insert(&txn).await?;
},
Err(_) => return Err(Error::Any("Failed to save file to storage".into())),
}
}
txn.commit().await?;
Ok(())
}
pub async fn update_project(
ctx: &AppContext,
id: i32,

View File

@@ -1,8 +1,11 @@
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use crate::models::_entities::skills::{Entity, Model};
use crate::models::_entities::skills::{Column, Entity, Model};
pub async fn get_all_skills(ctx: &AppContext) -> Result<Vec<Model>> {
let skills = Entity::find().all(&ctx.db).await?;
let skills = Entity::find()
.order_by_asc(Column::Name)
.all(&ctx.db).await?;
Ok(skills)
}

View File

@@ -0,0 +1,13 @@
use axum::extract::multipart::Field;
use loco_rs::prelude::*;
pub fn get_file_name_with_extension_from_field(
field: &Field<'_>,
default_extension: &str,
) -> Result<(String, String)> {
let file_name = field.file_name().ok_or_else(|| ModelError::Any("Failed to get file name".into()))?;
let mut parts = file_name.split('.').collect::<Vec<&str>>();
let extension = parts.pop().unwrap_or(default_extension);
let file_name = parts.join(".");
Ok((file_name, extension.to_string()))
}

View File

@@ -1 +1,2 @@
pub mod get_technologies_from_string;
pub mod get_file_name_with_extension;