better projects upload and mobile design
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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(
|
||||
|
@@ -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%}
|
@@ -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">
|
||||
|
@@ -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>
|
||||
|
@@ -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("/")
|
||||
|
@@ -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
13
src/services/auth.rs
Normal 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),
|
||||
}
|
||||
}
|
@@ -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();
|
||||
|
@@ -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| {
|
||||
|
@@ -3,3 +3,4 @@ pub mod jobs;
|
||||
pub mod skills;
|
||||
pub mod website;
|
||||
pub mod projects;
|
||||
pub mod auth;
|
@@ -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,
|
||||
|
@@ -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)
|
||||
}
|
||||
|
13
src/shared/get_file_name_with_extension.rs
Normal file
13
src/shared/get_file_name_with_extension.rs
Normal 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()))
|
||||
}
|
@@ -1 +1,2 @@
|
||||
pub mod get_technologies_from_string;
|
||||
pub mod get_file_name_with_extension;
|
Reference in New Issue
Block a user