Compare commits

...

4 Commits

17 changed files with 669 additions and 35 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-password-here
POSTGRES_DB=loft
DATABASE_URL=postgres://postgres:your-password-here@db:5432/loft
STORAGE_PATH=/app/storage

View File

@@ -1,7 +0,0 @@
backend_path := "backend"
watch:
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
watch-test:
cd {{backend_path}} && cargo watch -q -c -w src/ -x test

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# loft
A minimal, self-hosted web app (with a cloud storage option planned), built as a learning exercise to get more familiar with Axum and Svelte.
## What it does
- Register / log in (session-based auth)
- Upload, list, download, and delete files
- Browse files in a grid/tile view with image preview and prev/next navigation
- Stream file content via HTTP range requests — the groundwork for video streaming
## Requirements
- **To run:** Docker + Docker Compose — that's all (Rust/Node build inside the image).
- **To develop:** Rust (cargo + `sqlx-cli`), Node + pnpm, `just`, and Docker (for Postgres).
## Quick start
```bash
git clone https://git.stefiosif.dev/stefiosif/loft
cd loft
cp .env.example .env
# Run in Docker (http://localhost:3000):
docker compose up -d
# Run locally (http://localhost:5173):
cp backend/.env.example backend/.env
just db-setup
# inside tmux together frontend and backend
just watch
# or separately
just watch-backend
just watch-frontend
# API docs (Swagger UI, generated with utoipa): http://localhost:3000/swagger-ui
# Run tests:
cp backend/.env.test.example backend/.env.test
just watch-test
```
## Commands
Run `just --list` to see all recipes.

5
backend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft
STORAGE_PATH=/tmp/loft-files
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-password-here
POSTGRES_DB=loft

View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgres://postgres:your-password-here@localhost:5432/loft_test
STORAGE_PATH=/tmp/loft-files-test
ENVIRONMENT=test

171
backend/Cargo.lock generated
View File

@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -32,6 +38,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -205,6 +220,8 @@ dependencies = [
"tower_governor",
"tracing",
"tracing-subscriber",
"utoipa",
"utoipa-swagger-ui",
]
[[package]]
@@ -424,6 +441,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -499,6 +525,17 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "diff"
version = "0.1.13"
@@ -672,6 +709,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "flume"
version = "0.11.1"
@@ -1538,6 +1585,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -2333,6 +2390,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-multipart-rfc7578_2"
version = "0.9.0"
@@ -2681,6 +2772,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
@@ -3424,6 +3521,48 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utoipa"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]]
name = "utoipa-swagger-ui"
version = "9.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
dependencies = [
"axum",
"base64",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"url",
"utoipa",
"zip",
]
[[package]]
name = "uuid"
version = "1.23.1"
@@ -4113,8 +4252,40 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]

View File

@@ -23,6 +23,8 @@ rand = "0.10.1"
futures-util = "0.3.32"
mime_guess = "2.0.5"
tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] }
utoipa = { version = "5.5.0", features = ["axum_extras", "chrono"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
[dev-dependencies]
axum-test = "20.0.0"

View File

@@ -24,14 +24,16 @@ use tower_http::{
};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::{
model::{FileRepository, UserRepository},
web::{
mw_auth::{mw_ctx_resolver, mw_require_auth},
routes_file::routes_file,
routes_health::routes_health,
routes_login::routes_auth,
routes_file::{FileApi, routes_file},
routes_health::{HealthApi, routes_health},
routes_login::{AuthApi, routes_auth},
},
};
@@ -84,10 +86,15 @@ async fn main() -> Result<()> {
routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth));
}
let mut openapi = AuthApi::openapi();
openapi.merge(FileApi::openapi());
openapi.merge(HealthApi::openapi());
let app = Router::new()
.nest("/api", routes_file)
.nest("/api/auth", routes_auth)
.merge(routes_health())
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi))
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn_with_state(
user_repository,

View File

@@ -6,7 +6,7 @@ use sqlx::{PgPool, prelude::FromRow};
use crate::error::{LoftError, Result};
#[derive(Clone, Debug, Serialize, FromRow)]
#[derive(Clone, Debug, Serialize, FromRow, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FileRecord {
pub id: i64,

View File

@@ -7,6 +7,7 @@ use axum::{
routing::get,
};
use sqlx::types::uuid;
use utoipa::OpenApi;
use crate::{
ctx::Ctx,
@@ -14,6 +15,20 @@ use crate::{
model::{FileRecord, FileRepository},
};
#[derive(OpenApi)]
#[openapi(
paths(
list_files,
upload_file,
get_file,
delete_file,
download_file,
stream_part
),
components(schemas(FileRecord))
)]
pub struct FileApi;
pub fn routes_file(file_repository: FileRepository) -> Router {
Router::new()
.route("/files", get(list_files).post(upload_file))
@@ -23,11 +38,25 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
.with_state(file_repository)
}
/// Upload a file
#[utoipa::path(
post,
path = "/api/files",
tag = "files",
security(("cookie_auth" = [])),
request_body(content_type = "multipart/form-data", description = "Multipart form with a `file` field"),
responses(
(status = 201, description = "Uploaded file metadata", body = FileRecord),
(status = 400, description = "No file provided or malformed upload"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn upload_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
mut multipart: Multipart,
) -> Result<Json<FileRecord>> {
) -> Result<(StatusCode, Json<FileRecord>)> {
let mut uploaded: Option<(String, String, usize)> = None;
while let Some(field) = multipart.next_field().await? {
@@ -45,9 +74,23 @@ async fn upload_file(
.create_file_record(ctx.user_id(), &name, size, &key)
.await?;
Ok(Json(file_record))
Ok((StatusCode::CREATED, Json(file_record)))
}
/// Get a file's metadata
#[utoipa::path(
get,
path = "/api/files/{id}",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "File metadata", body = FileRecord),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn get_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -59,6 +102,20 @@ async fn get_file(
Ok(Json(record))
}
/// Download a file
#[utoipa::path(
get,
path = "/api/files/{id}/download",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "File bytes", content_type = "application/octet-stream"),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn download_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -70,6 +127,24 @@ async fn download_file(
Ok(Body::from_stream(stream))
}
/// Stream a byte range of a file (HTTP 206)
#[utoipa::path(
get,
path = "/api/files/{id}/stream_part",
tag = "files",
security(("cookie_auth" = [])),
params(
("id" = u64, Path, description = "File id"),
("Range" = Option<String>, Header, description = "Byte range, e.g. bytes=0-1023")
),
responses(
(status = 206, description = "Partial content"),
(status = 400, description = "Invalid range"),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn stream_part(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -117,6 +192,20 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> {
Ok((start, end))
}
/// Delete a file
#[utoipa::path(
delete,
path = "/api/files/{id}",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "Deleted file metadata", body = FileRecord),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn delete_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -128,6 +217,18 @@ async fn delete_file(
Ok(Json(file))
}
/// List the current user's files
#[utoipa::path(
get,
path = "/api/files",
tag = "files",
security(("cookie_auth" = [])),
responses(
(status = 200, description = "List user's files", body = Vec<FileRecord>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn list_files(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -138,7 +239,11 @@ async fn list_files(
#[cfg(test)]
mod tests {
use axum::{Router, http::header, middleware};
use axum::{
Router,
http::{StatusCode, header},
middleware,
};
use axum_test::{
TestServer,
multipart::{MultipartForm, Part},
@@ -284,7 +389,7 @@ mod tests {
))
.await;
res.assert_status_ok();
res.assert_status(StatusCode::CREATED);
let file = res.json::<serde_json::Value>();
assert_eq!(file["name"], "a.jpg");

View File

@@ -1,9 +1,21 @@
use axum::{Router, routing::get};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(paths(health))]
pub struct HealthApi;
pub fn routes_health() -> Router {
Router::new().route("/health", get(health))
}
/// Health check
#[utoipa::path(
get,
path = "/health",
tag = "health",
responses((status = 200, description = "Service is healthy"))
)]
async fn health() -> &'static str {
"up"
}

View File

@@ -4,9 +4,13 @@ use argon2::{
};
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
use rand::RngExt;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tower_cookies::{Cookie, Cookies};
use tracing::warn;
use utoipa::{
Modify, OpenApi,
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
};
use crate::{
error::{LoftError, Result},
@@ -14,6 +18,32 @@ use crate::{
web::AUTH_TOKEN,
};
#[derive(OpenApi)]
#[openapi(
info(
title = "loft API",
description = "Self-hosted media storage (Axum + SvelteKit)"
),
modifiers(&SecurityAddon),
paths(login, logout, register),
components(schemas(LoginPayload))
)]
pub struct AuthApi;
/// Registers the session-cookie auth scheme so protected endpoints show as secured.
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"cookie_auth",
SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(AUTH_TOKEN))),
);
}
}
}
pub fn routes_auth(user_repository: UserRepository) -> Router {
Router::new()
.route("/login", post(login))
@@ -22,6 +52,18 @@ pub fn routes_auth(user_repository: UserRepository) -> Router {
.with_state(user_repository)
}
/// Log in and start a session
#[utoipa::path(
post,
path = "/api/auth/login",
tag = "auth",
request_body = LoginPayload,
responses(
(status = 200, description = "Login successful"),
(status = 401, description = "Login failed"),
(status = 500, description = "Internal server error")
)
)]
async fn login(
State(user_repository): State<UserRepository>,
cookies: Cookies,
@@ -50,6 +92,17 @@ async fn login(
Ok(StatusCode::OK)
}
/// Log out and clear the session
#[utoipa::path(
post,
path = "/api/auth/logout",
tag = "auth",
// request_body = LoginPayload,
responses(
(status = 200, description = "Logout successful"),
(status = 500, description = "Internal server error")
)
)]
async fn logout(
State(user_repository): State<UserRepository>,
cookies: Cookies,
@@ -64,6 +117,18 @@ async fn logout(
Ok(StatusCode::OK)
}
/// Register a new account
#[utoipa::path(
post,
path = "/api/auth/register",
tag = "auth",
// request_body = LoginPayload,
responses(
(status = 201, description = "Registration successful"),
(status = 401, description = "Registration failed"),
(status = 500, description = "Internal server error")
)
)]
async fn register(
State(user_repository): State<UserRepository>,
Json(payload): Json<LoginPayload>,
@@ -76,7 +141,7 @@ async fn register(
warn!(
"Register fail, username {} already exists",
&payload.username
); // also fix "Login fail" typo
);
return Err(LoftError::RegisterFail);
}
@@ -108,7 +173,7 @@ fn create_cookie() -> Cookie<'static> {
.build()
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
struct LoginPayload {
username: String,
password: String,

View File

@@ -11,7 +11,7 @@ services:
volumes:
- loft_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d loft"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
@@ -21,7 +21,7 @@ services:
restart: unless-stopped
build: .
ports:
- "3000:3000"
- "3000:3000"
depends_on:
db:
condition: service_healthy

View File

@@ -2,16 +2,45 @@
import type { FileRecord } from '$lib/types';
import { fade } from 'svelte/transition';
import VideoPreview from './VideoPreview.svelte';
import ImagePreview from './ImagePreview.svelte';
let {
file,
onclose,
onprev,
onnext,
hasPrev = false,
hasNext = false
}: {
file: FileRecord;
onclose: () => void;
onprev?: () => void;
onnext?: () => void;
hasPrev?: boolean;
hasNext?: boolean;
} = $props();
let { file, onclose }: { file: FileRecord; onclose: () => void } = $props();
const isVideo = $derived(file.fileType.includes('video'));
</script>
const isImage = $derived(file.fileType.includes('image'));
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') onclose();
}}
/>
$effect(() => {
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
} else if (e.key === 'ArrowLeft' && hasPrev) {
e.preventDefault();
e.stopPropagation();
onprev?.();
} else if (e.key === 'ArrowRight' && hasNext) {
e.preventDefault();
e.stopPropagation();
onnext?.();
}
}
window.addEventListener('keydown', onKeydown, { capture: true });
return () => window.removeEventListener('keydown', onKeydown, { capture: true });
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -20,12 +49,73 @@
onclick={onclose}
transition:fade={{ duration: 150 }}
>
{#if hasPrev}
<button
class="absolute top-1/2 left-4 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-2 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
onclick={(e) => {
e.stopPropagation();
onprev?.();
}}
aria-label="Previous"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/if}
{#if hasNext}
<button
class="absolute top-1/2 right-4 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-black/40 p-2 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
onclick={(e) => {
e.stopPropagation();
onnext?.();
}}
aria-label="Next"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/if}
<div
class="relative aspect-video h-[85vh] max-w-[95vw] overflow-hidden rounded-xl border border-sky-200/20 bg-[#0f1117]"
class="relative overflow-hidden rounded-xl border border-sky-200/20 bg-[#0f1117] {isImage
? 'max-h-[85vh] max-w-[95vw]'
: 'aspect-video h-[85vh] max-w-[95vw]'}"
onclick={(e) => e.stopPropagation()}
>
{#if isVideo}
<VideoPreview src={`/api/files/${file.id}/stream_part`} />
{#key file.id}
{#if isVideo}
<VideoPreview src={`/api/files/${file.id}/stream_part`} />
{:else if isImage}
<ImagePreview src={`/api/files/${file.id}/download`} alt={file.name} />
{:else}
<div class="flex h-full w-full items-center justify-center text-sm text-white/40">
No preview available
</div>
{/if}
{/key}
{#if isVideo || isImage}
<div
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-linear-to-b from-black/70 to-transparent px-4 py-3"
>
@@ -52,10 +142,6 @@
</svg>
</button>
</div>
{:else}
<div class="flex h-full w-full items-center justify-center text-sm text-white/40">
No preview available
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
let { src, alt }: { src: string; alt: string } = $props();
</script>
<img {src} {alt} class="block max-h-[85vh] max-w-[95vw] object-contain" />

View File

@@ -29,6 +29,27 @@
fileRecords.filter((f) => f.name.toLowerCase().includes(search.toLowerCase()))
);
let previewIndex = $derived.by(() => {
if (!previewFile) {
return -1;
}
const id = previewFile.id;
return filteredFileRecords.findIndex((f) => f.id === id);
});
let hasPrev = $derived(previewIndex > 0);
let hasNext = $derived(previewIndex >= 0 && previewIndex < filteredFileRecords.length - 1);
function goPrev() {
if (hasPrev) {
previewFile = filteredFileRecords[previewIndex - 1];
}
}
function goNext() {
if (hasNext) {
previewFile = filteredFileRecords[previewIndex + 1];
}
}
let fileInput: HTMLInputElement;
async function handleUpload(event: Event) {
@@ -214,5 +235,12 @@
</div>
{#if previewFile}
<FilePreview file={previewFile} onclose={() => (previewFile = null)} />
<FilePreview
file={previewFile}
onclose={() => (previewFile = null)}
onprev={goPrev}
onnext={goNext}
{hasPrev}
{hasNext}
/>
{/if}

106
justfile Normal file
View File

@@ -0,0 +1,106 @@
backend_path := "backend"
frontend_path := "frontend"
# Run backend + frontend watchers in split tmux panes (run from inside tmux)
[group('dev')]
watch:
#!/usr/bin/env bash
tmux split-window -h 'just watch-frontend'
just watch-backend
# Run the backend with hot reload
[group('dev')]
watch-backend:
cd {{backend_path}} && cargo watch -q -c -w src/ -x run
# Run the frontend dev server
[group('dev')]
watch-frontend:
cd {{frontend_path}} && pnpm dev
# Run the backend tests in watch mode (sets up the test DB first)
[group('test')]
watch-test: test-db-setup
cd {{backend_path}} && cargo watch -q -c -w src/ -x test
# Run the Playwright e2e suite (sets up the test DB + servers)
[group('test')]
e2e:
mkdir -p /tmp/loft-files-test
cd {{backend_path}} && set -a && . ./.env.test && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
docker compose exec -T db psql -U postgres -d loft_test -c "TRUNCATE users, sessions, file_records CASCADE"
cd {{frontend_path}} && pnpm exec playwright test
# Format backend + frontend
[group('quality')]
fmt: fmt-backend fmt-frontend
# Format the backend (cargo fmt)
[group('quality')]
fmt-backend:
cd {{backend_path}} && cargo fmt
# Format the frontend (prettier)
[group('quality')]
fmt-frontend:
cd {{frontend_path}} && pnpm exec prettier --write .
# Lint the backend (cargo clippy)
[group('quality')]
lint-backend:
cd {{backend_path}} && cargo clippy
# Stage the given files (git add)
[group('git')]
stage +files:
git add {{files}}
# Commit staged changes with a message
[group('git')]
commit message:
git commit -m "{{message}}"
# Commit staged changes with a Claude co-author trailer
[group('git')]
co-commit message:
git commit -m "{{message}}" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
# Push main to both the github and gitea remotes
[group('git')]
push:
git push -u github main && git push -u gitea main
# Create (if missing) + migrate the test database
[group('db')]
test-db-setup: db-up
cd {{backend_path}} && set -a && . ./.env.test && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
# Bring up the dev database container
[group('db')]
db-up:
docker compose up -d db
# Create (if missing) + migrate the dev database
[group('db')]
db-setup: db-up
cd {{backend_path}} && set -a && . ./.env && set +a && (sqlx database create 2>/dev/null || true) && sqlx migrate run
# Apply pending migrations to the dev database
[group('db')]
migrate:
cd {{backend_path}} && set -a && . ./.env && set +a && sqlx migrate run
# Open a psql shell on the dev database
[group('db')]
db-shell:
docker compose exec db psql -U postgres -d loft
# Run a SQL query against the dev database
[group('db')]
db-query query:
docker compose exec db psql -U postgres -d loft -c "{{query}}"
# Open a shell inside the app container
[group('db')]
app-shell:
docker compose exec app sh