Compare commits
4 Commits
2616896ecb
...
d153e1c251
| Author | SHA1 | Date | |
|---|---|---|---|
| d153e1c251 | |||
| be3f7da565 | |||
| 91459db5ec | |||
| 450b3fdf52 |
@@ -11,7 +11,6 @@ use axum::{
|
|||||||
extract::DefaultBodyLimit,
|
extract::DefaultBodyLimit,
|
||||||
http::{HeaderValue, Method, header},
|
http::{HeaderValue, Method, header},
|
||||||
middleware,
|
middleware,
|
||||||
response::Response,
|
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
@@ -49,26 +48,6 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
let governor_conf_auth = GovernorConfigBuilder::default()
|
|
||||||
.per_second(4)
|
|
||||||
.burst_size(2)
|
|
||||||
.key_extractor(SmartIpKeyExtractor)
|
|
||||||
.finish()
|
|
||||||
.expect("failed to initialize rate limiter configurations");
|
|
||||||
let governor_auth_limiter = governor_conf_auth.limiter().clone();
|
|
||||||
let interval = Duration::from_secs(60);
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
std::thread::sleep(interval);
|
|
||||||
let len = governor_auth_limiter.len();
|
|
||||||
if len > 0 {
|
|
||||||
info!("rate limiting auth storage size: {len}");
|
|
||||||
}
|
|
||||||
governor_auth_limiter.retain_recent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
|
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
|
||||||
let pool = PgPool::connect(&database_url).await?;
|
let pool = PgPool::connect(&database_url).await?;
|
||||||
@@ -79,15 +58,37 @@ async fn main() -> Result<()> {
|
|||||||
.layer(DefaultBodyLimit::max(BODY_LIMIT));
|
.layer(DefaultBodyLimit::max(BODY_LIMIT));
|
||||||
|
|
||||||
let user_repository = UserRepository::new(pool);
|
let user_repository = UserRepository::new(pool);
|
||||||
let routes_auth =
|
let mut routes_auth = routes_auth(user_repository.clone());
|
||||||
routes_auth(user_repository.clone()).layer(GovernorLayer::new(governor_conf_auth));
|
|
||||||
|
if std::env::var("ENVIRONMENT").unwrap_or_default() != "test" {
|
||||||
|
let governor_conf_auth = GovernorConfigBuilder::default()
|
||||||
|
.per_second(4)
|
||||||
|
.burst_size(2)
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
|
.finish()
|
||||||
|
.expect("failed to initialize rate limiter configurations");
|
||||||
|
let governor_auth_limiter = governor_conf_auth.limiter().clone();
|
||||||
|
let interval = Duration::from_secs(60);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(interval);
|
||||||
|
let len = governor_auth_limiter.len();
|
||||||
|
if len > 0 {
|
||||||
|
info!("rate limiting auth storage size: {len}");
|
||||||
|
}
|
||||||
|
governor_auth_limiter.retain_recent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth));
|
||||||
|
}
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
.nest("/api/auth", routes_auth)
|
.nest("/api/auth", routes_auth)
|
||||||
.merge(routes_health())
|
.merge(routes_health())
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(middleware::map_response(main_response_mapper))
|
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
user_repository,
|
user_repository,
|
||||||
mw_ctx_resolver,
|
mw_ctx_resolver,
|
||||||
@@ -115,8 +116,3 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn main_response_mapper(res: Response) -> Response {
|
|
||||||
info!("response mapper: {}", res.status());
|
|
||||||
res
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use tower_cookies::Cookies;
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
use crate::{ctx::Ctx, error::LoftError, model::UserRepository, web::AUTH_TOKEN};
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
error::{LoftError, Result},
|
||||||
|
model::UserRepository,
|
||||||
|
web::AUTH_TOKEN,
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn mw_require_auth(
|
pub async fn mw_require_auth(ctx: Result<Ctx>, req: Request, next: Next) -> Result<Response> {
|
||||||
ctx: Result<Ctx, LoftError>,
|
|
||||||
req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, LoftError> {
|
|
||||||
ctx?;
|
ctx?;
|
||||||
|
|
||||||
Ok(next.run(req).await)
|
Ok(next.run(req).await)
|
||||||
@@ -22,7 +23,7 @@ pub async fn mw_ctx_resolver(
|
|||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
mut req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, LoftError> {
|
) -> Result<Response> {
|
||||||
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
||||||
|
|
||||||
let result_ctx = match auth_token {
|
let result_ctx = match auth_token {
|
||||||
@@ -46,7 +47,7 @@ impl<S: Send + Sync> FromRequestParts<S> for Ctx {
|
|||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
parts
|
parts
|
||||||
.extensions
|
.extensions
|
||||||
.get::<Result<Ctx, LoftError>>()
|
.get::<Result<Ctx>>()
|
||||||
.ok_or(LoftError::AuthFailCtxNotInRequestExt)?
|
.ok_or(LoftError::AuthFailCtxNotInRequestExt)?
|
||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async fn upload_file(
|
|||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<FileRecord>, LoftError> {
|
) -> Result<Json<FileRecord>> {
|
||||||
let mut uploaded: Option<(String, String, usize)> = None;
|
let mut uploaded: Option<(String, String, usize)> = None;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await? {
|
while let Some(field) = multipart.next_field().await? {
|
||||||
@@ -52,7 +52,7 @@ async fn get_file(
|
|||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<FileRecord>, LoftError> {
|
) -> Result<Json<FileRecord>> {
|
||||||
let record = file_repository
|
let record = file_repository
|
||||||
.get_file(file_id as i64, ctx.user_id())
|
.get_file(file_id as i64, ctx.user_id())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -121,7 +121,7 @@ async fn delete_file(
|
|||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<FileRecord>, LoftError> {
|
) -> Result<Json<FileRecord>> {
|
||||||
let file = file_repository
|
let file = file_repository
|
||||||
.delete_file(file_id as i64, ctx.user_id())
|
.delete_file(file_id as i64, ctx.user_id())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -131,7 +131,7 @@ async fn delete_file(
|
|||||||
async fn list_files(
|
async fn list_files(
|
||||||
State(file_repository): State<FileRepository>,
|
State(file_repository): State<FileRepository>,
|
||||||
ctx: Ctx,
|
ctx: Ctx,
|
||||||
) -> Result<Json<Vec<FileRecord>>, LoftError> {
|
) -> Result<Json<Vec<FileRecord>>> {
|
||||||
let files = file_repository.list_files(ctx.user_id()).await?;
|
let files = file_repository.list_files(ctx.user_id()).await?;
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async fn login(
|
|||||||
State(user_repository): State<UserRepository>,
|
State(user_repository): State<UserRepository>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
Json(payload): Json<LoginPayload>,
|
Json(payload): Json<LoginPayload>,
|
||||||
) -> Result<StatusCode, LoftError> {
|
) -> Result<StatusCode> {
|
||||||
let user = user_repository
|
let user = user_repository
|
||||||
.find_by_username(&payload.username)
|
.find_by_username(&payload.username)
|
||||||
.await?
|
.await?
|
||||||
@@ -53,7 +53,7 @@ async fn login(
|
|||||||
async fn logout(
|
async fn logout(
|
||||||
State(user_repository): State<UserRepository>,
|
State(user_repository): State<UserRepository>,
|
||||||
cookies: Cookies,
|
cookies: Cookies,
|
||||||
) -> Result<StatusCode, LoftError> {
|
) -> Result<StatusCode> {
|
||||||
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
let auth_token = cookies.get(AUTH_TOKEN).map(|c| c.value().to_string());
|
||||||
|
|
||||||
if let Some(auth_token) = auth_token {
|
if let Some(auth_token) = auth_token {
|
||||||
@@ -67,7 +67,7 @@ async fn logout(
|
|||||||
async fn register(
|
async fn register(
|
||||||
State(user_repository): State<UserRepository>,
|
State(user_repository): State<UserRepository>,
|
||||||
Json(payload): Json<LoginPayload>,
|
Json(payload): Json<LoginPayload>,
|
||||||
) -> Result<StatusCode, LoftError> {
|
) -> Result<StatusCode> {
|
||||||
if user_repository
|
if user_repository
|
||||||
.find_by_username(&payload.username)
|
.find_by_username(&payload.username)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
213
frontend/src/lib/components/TileCard.svelte
Normal file
213
frontend/src/lib/components/TileCard.svelte
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { FileRecord } from '$lib/types';
|
||||||
|
import { formatName } from '$lib/format';
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
selected,
|
||||||
|
onselect,
|
||||||
|
onpreview,
|
||||||
|
ondownload,
|
||||||
|
ondelete
|
||||||
|
}: {
|
||||||
|
file: FileRecord;
|
||||||
|
selected: boolean;
|
||||||
|
onselect: () => void;
|
||||||
|
onpreview: () => void;
|
||||||
|
ondownload: () => void;
|
||||||
|
ondelete: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isImage = $derived(file.fileType.includes('image'));
|
||||||
|
const isVideo = $derived(file.fileType.includes('video'));
|
||||||
|
|
||||||
|
let confirming = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={onselect}
|
||||||
|
ondblclick={onpreview}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onpreview();
|
||||||
|
} else if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onselect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50
|
||||||
|
{selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}"
|
||||||
|
>
|
||||||
|
{#if isImage}
|
||||||
|
<img
|
||||||
|
src={`/api/files/${file.id}/download`}
|
||||||
|
alt={file.name}
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full w-full items-center justify-center bg-white/5 text-white/30">
|
||||||
|
{#if isVideo}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2.18" />
|
||||||
|
<line x1="7" y1="2" x2="7" y2="22" />
|
||||||
|
<line x1="17" y1="2" x2="17" y2="22" />
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
|
<line x1="2" y1="7" x2="7" y2="7" />
|
||||||
|
<line x1="2" y1="17" x2="7" y2="17" />
|
||||||
|
<line x1="17" y1="17" x2="22" y2="17" />
|
||||||
|
<line x1="17" y1="7" x2="22" y2="7" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||||
|
<polyline points="13 2 13 9 20 9" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isVideo}
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-black/50">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 text-white"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 to-transparent px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span class="block truncate text-xs font-medium text-white">{formatName(file.name)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute top-1 right-1 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
{#if confirming}
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-emerald-400"
|
||||||
|
title="Confirm delete"
|
||||||
|
aria-label="Confirm delete"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
ondelete();
|
||||||
|
confirming = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-white"
|
||||||
|
title="Cancel"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
confirming = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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.5"
|
||||||
|
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>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-emerald-400"
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
ondownload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-red-400"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
confirming = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { FileRecord } from '$lib/types';
|
import type { FileRecord } from '$lib/types';
|
||||||
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
|
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
|
||||||
import FileRow from '$lib/components/FileRow.svelte';
|
import FileRow from '$lib/components/FileRow.svelte';
|
||||||
|
import TileCard from '$lib/components/TileCard.svelte';
|
||||||
import FilePreview from '$lib/components/FilePreview.svelte';
|
import FilePreview from '$lib/components/FilePreview.svelte';
|
||||||
|
|
||||||
let fileRecords = $state<FileRecord[]>([]);
|
let fileRecords = $state<FileRecord[]>([]);
|
||||||
@@ -12,6 +13,12 @@
|
|||||||
let previewFile = $state<FileRecord | null>(null);
|
let previewFile = $state<FileRecord | null>(null);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let viewMode = $state<'rows' | 'tiles'>('rows');
|
||||||
|
|
||||||
|
function setViewMode(mode: 'rows' | 'tiles') {
|
||||||
|
viewMode = mode;
|
||||||
|
localStorage.setItem('viewMode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
fileRecords = await loadFiles();
|
fileRecords = await loadFiles();
|
||||||
@@ -54,7 +61,11 @@
|
|||||||
window.location.href = '/auth';
|
window.location.href = '/auth';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(refresh);
|
onMount(() => {
|
||||||
|
const saved = localStorage.getItem('viewMode');
|
||||||
|
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full px-4 py-8">
|
<div class="w-full px-4 py-8">
|
||||||
@@ -92,40 +103,114 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
|
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
|
||||||
<div class="mb-1">
|
<div class="mb-1 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
class="cursor-pointer rounded-sm border border-sky-200/20 px-2 py-1 text-sm text-white/60 transition-colors hover:border-sky-200/40"
|
class="cursor-pointer rounded-sm border border-sky-200/20 px-2 py-1 text-sm text-white/60 transition-colors hover:border-sky-200/40"
|
||||||
onclick={() => fileInput.click()}
|
onclick={() => fileInput.click()}
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div class="flex gap-0.5 rounded-sm border border-sky-200/20 p-0.5">
|
||||||
|
<button
|
||||||
<div class="rounded-sm border border-sky-200/20">
|
class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'rows'
|
||||||
<!-- Header row -->
|
? 'bg-white/10 text-white'
|
||||||
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
|
: 'text-white/40 hover:text-white/70'}"
|
||||||
<span class="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span>
|
title="List view"
|
||||||
<span class="w-40 py-2 text-sm text-white/40">Size</span>
|
aria-label="List view"
|
||||||
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span>
|
onclick={() => setViewMode('rows')}
|
||||||
<div class="w-px self-stretch bg-sky-200/20"></div>
|
>
|
||||||
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span>
|
<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="8" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="8" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'tiles'
|
||||||
|
? 'bg-white/10 text-white'
|
||||||
|
: 'text-white/40 hover:text-white/70'}"
|
||||||
|
title="Tile view"
|
||||||
|
aria-label="Tile view"
|
||||||
|
onclick={() => setViewMode('tiles')}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
|
||||||
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
|
|
||||||
<FileRow
|
|
||||||
file={fileRecord}
|
|
||||||
selected={selectedFileId === fileRecord.id}
|
|
||||||
onselect={() => (selectedFileId = fileRecord.id)}
|
|
||||||
onpreview={() => (previewFile = fileRecord)}
|
|
||||||
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
|
|
||||||
ondelete={() => handleDelete(fileRecord.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#if !loading}<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if viewMode === 'rows'}
|
||||||
|
<div class="rounded-sm border border-sky-200/20">
|
||||||
|
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
|
||||||
|
<span class="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span>
|
||||||
|
<span class="w-40 py-2 text-sm text-white/40">Size</span>
|
||||||
|
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span>
|
||||||
|
<div class="w-px self-stretch bg-sky-200/20"></div>
|
||||||
|
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||||
|
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
|
||||||
|
<FileRow
|
||||||
|
file={fileRecord}
|
||||||
|
selected={selectedFileId === fileRecord.id}
|
||||||
|
onselect={() => (selectedFileId = fileRecord.id)}
|
||||||
|
onpreview={() => (previewFile = fileRecord)}
|
||||||
|
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
|
||||||
|
ondelete={() => handleDelete(fileRecord.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if !loading}
|
||||||
|
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||||
|
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
|
||||||
|
<TileCard
|
||||||
|
file={fileRecord}
|
||||||
|
selected={selectedFileId === fileRecord.id}
|
||||||
|
onselect={() => (selectedFileId = fileRecord.id)}
|
||||||
|
onpreview={() => (previewFile = fileRecord)}
|
||||||
|
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
|
||||||
|
ondelete={() => handleDelete(fileRecord.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if !loading}
|
||||||
|
<p class="col-span-full px-4 py-2 text-sm text-white/30">No files yet.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if previewFile}
|
{#if previewFile}
|
||||||
|
|||||||
Reference in New Issue
Block a user