Compare commits

..

4 Commits

24 changed files with 658 additions and 48 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
**/target
**/node_modules
**/.svelte-kit
frontend/build
.git
**/.env
**/.env.*

3
.gitignore vendored
View File

@@ -18,5 +18,8 @@ node_modules/
# ─── env / secrets ─── #
.env
.env.test
.env.local
.env.*.local
docs/

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:22-alpine AS frontend-builder
WORKDIR /frontend
RUN npm install -g pnpm@10
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM rust:1-bookworm AS backend-builder
WORKDIR /backend
ENV SQLX_OFFLINE=true
COPY backend/ ./
RUN cargo build --release
FROM debian:12-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /app/storage
COPY --from=backend-builder /backend/target/release/backend /app/backend
COPY --from=frontend-builder /frontend/build /app/static
ENV STORAGE_PATH=/app/storage
EXPOSE 3000
CMD ["/app/backend"]

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM sessions WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "11e96cfd8c2736f13ce55975ea910dd68640f6f14e38a4b3342d514804e3de27"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT *\n FROM users u\n WHERE u.username = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "24fd2306e40be21dc82660ec3f42f95928849706ecb75fe1cb8b606aa3cf2971"
}

View File

@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT *\n FROM file_records fr\n WHERE user_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "file_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "size",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "uploaded_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "storage_key",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c"
}

View File

@@ -0,0 +1,62 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO file_records (user_id, name, file_type, size, storage_key)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "file_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "size",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "uploaded_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "storage_key",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Int8",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (username, password_hash)\n VALUES ($1, $2)\n RETURNING *\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "65d6e198b36dc758d3da0e29374e5fabc8633a63f6c213b61a7e1902baad157b"
}

View File

@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT *\n FROM sessions s\n WHERE s.id = $1\n AND s.expires_at > NOW()\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "7d78918485f2c98b302a9d4c0801ba3a0f3af9273db03691317ab5e8676d61b7"
}

View File

@@ -0,0 +1,42 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO sessions (id, user_id, expires_at)\n VALUES ($1, $2, $3)\n RETURNING *\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "89c32ebfbbdf3f7d6fe47becb7b074a737b1627bc9c4cabbf2abe145d842b8f9"
}

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT *\n FROM file_records fr\n WHERE fr.id = $1\n AND fr.user_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "file_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "size",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "uploaded_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "storage_key",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0"
}

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM file_records\n WHERE id = $1\n AND user_id = $2\n RETURNING *\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "file_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "size",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "uploaded_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "storage_key",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d"
}

View File

@@ -13,7 +13,11 @@ use axum::{
};
use sqlx::PgPool;
use tower_cookies::CookieManagerLayer;
use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@@ -39,8 +43,10 @@ async fn main() -> Result<()> {
.init();
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = PgPool::connect(&database_url).await.unwrap();
sqlx::migrate!().run(&pool).await?;
let file_repository = FileRepository::new(pool.clone())?;
let routes_file = routes_file(file_repository.clone())
.route_layer(middleware::from_fn(mw_require_auth))
@@ -67,7 +73,9 @@ async fn main() -> Result<()> {
.allow_credentials(true)
.allow_headers([header::CONTENT_TYPE]),
)
.fallback_service(ServeDir::new("./"));
.fallback_service(
ServeDir::new("/app/static").fallback(ServeFile::new("/app/static/index.html")),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
info!("listening on {}", listener.local_addr().unwrap());

View File

@@ -106,12 +106,13 @@ impl FileRepository {
pub async fn download_file(
&self,
file_id: i64,
user_id: i64,
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
);
let record = self.get_file(file_id).await?;
let record = self.get_file(file_id, user_id).await?;
info!("Downloading file \"{}\"", file_id);
let reader = self.op.reader(&record.storage_key).await.unwrap();
let stream = reader.into_bytes_stream(0..).await.unwrap();
@@ -119,7 +120,7 @@ impl FileRepository {
Ok(stream)
}
pub async fn get_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
@@ -130,8 +131,10 @@ impl FileRepository {
SELECT *
FROM file_records fr
WHERE fr.id = $1
AND fr.user_id = $2
"#,
file_id
file_id,
user_id
)
.fetch_optional(&self.pool)
.await
@@ -141,12 +144,12 @@ impl FileRepository {
Ok(record)
}
pub async fn delete_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
pub async fn delete_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
);
let record = self.get_file(file_id).await?;
let record = self.get_file(file_id, user_id).await?;
info!("Deleting file bytes \"{}\"", file_id);
self.op.delete(&record.storage_key).await.unwrap();
@@ -156,9 +159,11 @@ impl FileRepository {
r#"
DELETE FROM file_records
WHERE id = $1
AND user_id = $2
RETURNING *
"#,
file_id
file_id,
user_id
)
.fetch_optional(&self.pool)
.await
@@ -407,7 +412,10 @@ mod tests {
.await
.unwrap();
let _downloaded = file_repository.download_file(file_record.id).await.unwrap();
let _downloaded = file_repository
.download_file(file_record.id, user.id)
.await
.unwrap();
//TODO: add assert
@@ -422,7 +430,7 @@ mod tests {
truncate_file_records(&file_repository.pool).await;
assert!(matches!(
file_repository.download_file(i64::MAX).await,
file_repository.download_file(i64::MAX, 1).await,
Err(LoftError::FileIdNotFound)
));
}
@@ -453,10 +461,13 @@ mod tests {
.await
.unwrap();
file_repository.delete_file(file_record.id).await.unwrap();
file_repository
.delete_file(file_record.id, user.id)
.await
.unwrap();
assert!(matches!(
file_repository.download_file(file_record.id).await,
file_repository.download_file(file_record.id, user.id).await,
Err(LoftError::FileIdNotFound)
));
truncate_file_records(&file_repository.pool).await;
@@ -469,7 +480,7 @@ mod tests {
let file_repository = file_repository().await.unwrap();
assert!(matches!(
file_repository.delete_file(99).await,
file_repository.delete_file(99, 1).await,
Err(LoftError::FileIdNotFound)
));
}

View File

@@ -57,28 +57,37 @@ async fn upload_file(
#[axum::debug_handler]
async fn get_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>, LoftError> {
let record = file_repository.get_file(file_id as i64).await?;
let record = file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
Ok(Json(record))
}
#[axum::debug_handler]
async fn download_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse, LoftError> {
let stream = file_repository.download_file(file_id as i64).await?;
let stream = file_repository
.download_file(file_id as i64, ctx.user_id())
.await?;
Ok(Body::from_stream(stream))
}
async fn delete_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>, LoftError> {
info!("handler: delete_file");
let file = file_repository.delete_file(file_id as i64).await?;
let file = file_repository
.delete_file(file_id as i64, ctx.user_id())
.await?;
Ok(Json(file))
}

36
compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
db:
image: docker.io/library/postgres:16
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=loft
ports:
- "5432:5432"
volumes:
- loft_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d loft"]
interval: 5s
timeout: 5s
retries: 5
app:
image: loft
restart: unless-stopped
build: .
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://postgres:pass@db:5432/loft
- STORAGE_PATH=/app/storage
volumes:
- loft_storage:/app/storage
volumes:
loft_db_data:
loft_storage:

View File

@@ -23,6 +23,7 @@
"@neoconfetti/svelte": "^2.2.2",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
@@ -46,5 +47,8 @@
"vite": "^7.3.1",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.0.2"
},
"dependencies": {
"plyr": "^3.8.4"
}
}

View File

@@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
plyr:
specifier: ^3.8.4
version: 3.8.4
devDependencies:
'@eslint/compat':
specifier: ^2.0.3
@@ -26,6 +30,9 @@ importers:
'@sveltejs/adapter-auto':
specifier: ^7.0.0
version: 7.0.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))
'@sveltejs/adapter-static':
specifier: ^3.0.10
version: 3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))
'@sveltejs/kit':
specifier: ^2.50.2
version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
@@ -491,6 +498,11 @@ packages:
peerDependencies:
'@sveltejs/kit': ^2.0.0
'@sveltejs/adapter-static@3.0.10':
resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==}
peerDependencies:
'@sveltejs/kit': ^2.0.0
'@sveltejs/kit@2.55.0':
resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==}
engines: {node: '>=18.13'}
@@ -803,6 +815,9 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
core-js@3.49.0:
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -812,6 +827,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
custom-event-polyfill@1.0.7:
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1119,6 +1137,9 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
loadjs@4.3.0:
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@@ -1199,6 +1220,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
plyr@3.8.4:
resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
@@ -1313,6 +1337,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
rangetouch@2.0.1:
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -1434,6 +1461,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
url-polyfill@1.1.14:
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1812,6 +1842,10 @@ snapshots:
dependencies:
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))':
dependencies:
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2151,6 +2185,8 @@ snapshots:
cookie@0.6.0: {}
core-js@3.49.0: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2159,6 +2195,8 @@ snapshots:
cssesc@3.0.0: {}
custom-event-polyfill@1.0.7: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -2455,6 +2493,8 @@ snapshots:
lilconfig@2.1.0: {}
loadjs@4.3.0: {}
locate-character@3.0.0: {}
locate-path@6.0.0:
@@ -2518,6 +2558,14 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
plyr@3.8.4:
dependencies:
core-js: 3.49.0
custom-event-polyfill: 1.0.7
loadjs: 4.3.0
rangetouch: 2.0.1
url-polyfill: 1.1.14
pngjs@7.0.0: {}
postcss-load-config@3.1.4(postcss@8.5.8):
@@ -2568,6 +2616,8 @@ snapshots:
punycode@2.3.1: {}
rangetouch@2.0.1: {}
readdirp@4.1.2: {}
rollup@4.60.1:
@@ -2716,6 +2766,8 @@ snapshots:
dependencies:
punycode: 2.3.1
url-polyfill@1.1.14: {}
util-deprecate@1.0.2: {}
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0):

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type { FileRecord } from '$lib/types';
import { fade } from 'svelte/transition';
import VideoPreview from './VideoPreview.svelte';
let { file, onclose }: { file: FileRecord; onclose: () => void } = $props();
const isVideo = $derived(file.fileType.includes('video'));
</script>
<svelte:window
@@ -14,38 +16,46 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onclick={onclose}
transition:fade={{ duration: 150 }}
>
<div
class="flex h-2/3 w-2/3 flex-col rounded-sm border border-sky-200/20 bg-[#0f1117] p-6"
class="relative aspect-video h-[85vh] max-w-[95vw] overflow-hidden rounded-xl border border-sky-200/20 bg-[#0f1117]"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium text-white">{file.name}</span>
<button
class="cursor-pointer text-white/40 transition-colors hover:text-white"
onclick={onclose}
aria-label="Close"
{#if isVideo}
<VideoPreview src={`/api/files/${file.id}/download`} />
<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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
<span class="pointer-events-auto truncate pr-3 text-sm font-medium text-white"
>{file.name}</span
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="flex flex-1 items-center justify-center text-sm text-white/20">
preview coming soon
</div>
<button
class="pointer-events-auto cursor-pointer text-white/70 transition-colors hover:text-white"
onclick={onclose}
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</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,28 @@
<script lang="ts">
import Plyr from 'plyr';
import 'plyr/dist/plyr.css';
let { src }: { src: string } = $props();
let videoElement: HTMLVideoElement;
$effect(() => {
const player = new Plyr(videoElement, {
ratio: '16:9',
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'captions',
'settings',
'fullscreen'
]
});
return () => player.destroy();
});
</script>
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoElement} {src} poster="/video-placeholder.png" playsinline controls></video>

View File

@@ -2,5 +2,6 @@ export type FileRecord = {
id: number;
name: string;
size: number;
fileType: string;
uploadedAt: string;
};

View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;

View File

@@ -1,6 +1,4 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@import '@fontsource/fira-mono';
:root {
@@ -13,6 +11,8 @@
--column-margin-top: 4rem;
font-family: var(--font-body);
color: var(--color-text);
--plyr-color-main: var(--color-sky-400);
--plyr-video-background: #0f1117;
}
body {

View File

@@ -1,12 +1,9 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
adapter: adapter({ fallback: 'index.html' })
}
};