list warrens + explore nested folders

This commit is contained in:
2025-07-12 06:39:43 +02:00
parent f9f55895ed
commit 4d0765c53b
38 changed files with 1877 additions and 93 deletions

View File

@@ -1,5 +1,6 @@
.gitignore .gitignore
frontend/.env
frontend/dist frontend/dist
frontend/.nuxt frontend/.nuxt
frontend/.output frontend/.output

View File

@@ -16,8 +16,10 @@ RUN npm run generate
FROM rust:bookworm AS backend-builder FROM rust:bookworm AS backend-builder
WORKDIR /usr/src/warren WORKDIR /usr/src/warren
COPY backend/Cargo.toml backend/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release
COPY backend/ . COPY backend/ .
RUN cargo build --release RUN touch src/main.rs && cargo build --release
FROM debian:bookworm FROM debian:bookworm

2
backend/.gitignore vendored
View File

@@ -1 +1,3 @@
target target
serve
.env

1339
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,5 +8,10 @@ axum = "0.8.4"
dotenv = "0.15.0" dotenv = "0.15.0"
env_logger = "0.11.8" env_logger = "0.11.8"
log = "0.4.27" log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] }
thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] } tokio = { version = "1.46.1", features = ["full"] }
tower-http = { version = "0.6.6", features = ["fs"] } tower-http = { version = "0.6.6", features = ["cors", "fs"] }
uuid = { version = "1.17.0", features = ["serde"] }

View File

@@ -0,0 +1,7 @@
CREATE TABLE warrens (
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
path VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_warrens_path ON warrens(path);

View File

@@ -0,0 +1 @@
ALTER TABLE warrens ADD COLUMN name VARCHAR NOT NULL;

10
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
use crate::server::Router;
mod state;
mod warrens;
pub use state::AppState;
pub(super) fn router() -> Router {
Router::new().nest("/warrens", warrens::router())
}

21
backend/src/api/state.rs Normal file
View File

@@ -0,0 +1,21 @@
use sqlx::{Pool, Postgres};
#[derive(Debug, Clone)]
pub struct AppState {
pool: Pool<Postgres>,
serve_dir: String,
}
impl AppState {
pub fn new(pool: Pool<Postgres>, serve_dir: String) -> Self {
Self { pool, serve_dir }
}
pub fn pool(&self) -> &Pool<Postgres> {
&self.pool
}
pub fn serve_dir(&self) -> &str {
&self.serve_dir
}
}

View File

@@ -0,0 +1,27 @@
use axum::Json;
use axum::extract::{Path, State};
use serde::Deserialize;
use uuid::Uuid;
use crate::Result;
use crate::api::AppState;
use crate::warrens::Warren;
use crate::fs::DirectoryEntry;
#[derive(Deserialize)]
pub(super) struct WarrenRequestPath {
warren_id: Uuid,
rest: Option<String>,
}
pub(super) async fn route(
State(state): State<AppState>,
Path(WarrenRequestPath { warren_id, rest }): Path<WarrenRequestPath>,
) -> Result<Json<Vec<DirectoryEntry>>> {
let warren = Warren::get(state.pool(), &warren_id).await?;
let entries = warren.read_path(state.serve_dir(), rest).await?;
Ok(Json(entries))
}

View File

@@ -0,0 +1,9 @@
use axum::{Json, extract::State};
use crate::{Result, api::AppState, warrens::Warren};
pub(super) async fn route(State(state): State<AppState>) -> Result<Json<Vec<Warren>>> {
let warrens = Warren::list(state.pool()).await?;
Ok(Json(warrens))
}

View File

@@ -0,0 +1,13 @@
mod get_warren_path;
mod list_warrens;
use axum::routing::get;
use crate::server::Router;
pub(super) fn router() -> Router {
Router::new()
.route("/", get(list_warrens::route))
.route("/{warren_id}", get(get_warren_path::route))
.route("/{warren_id}/{*rest}", get(get_warren_path::route))
}

28
backend/src/db/mod.rs Normal file
View File

@@ -0,0 +1,28 @@
use std::env;
use sqlx::{Connection, PgConnection, Pool, Postgres, postgres::PgPoolOptions};
use crate::error::AppError;
pub async fn get_postgres_pool() -> Result<Pool<Postgres>, AppError> {
let host = env::var("POSTGRES_HOST")?;
let port = env::var("POSTGRES_PORT")?;
let user = env::var("POSTGRES_USER")?;
let password = env::var("POSTGRES_PASSWORD")?;
let database = env::var("POSTGRES_DATABASE")?;
let base_url = format!("postgres://{user}:{password}@{host}:{port}");
let mut connection = PgConnection::connect(&base_url).await?;
let _ = sqlx::query(&format!("CREATE DATABASE {database}"))
.execute(&mut connection)
.await;
connection.close().await?;
let url = format!("{base_url}/{database}");
let pool = PgPoolOptions::new().connect(&url).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}

44
backend/src/error.rs Normal file
View File

@@ -0,0 +1,44 @@
use axum::{
body::Body,
http::{StatusCode, header::InvalidHeaderValue},
response::{IntoResponse, Response},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("IO: {0}")]
Io(#[from] std::io::Error),
#[error("Could not get file name")]
FileName,
#[error("SQLx: {0}")]
Sqlx(#[from] sqlx::Error),
#[error("Failed to migrate the database")]
DatabaseMigration(#[from] sqlx::migrate::MigrateError),
#[error("Var: {0}")]
Var(#[from] std::env::VarError),
#[error("InvalidHeaderValue: {0}")]
InvalidHeaderValue(#[from] InvalidHeaderValue),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
Self::Io(error) => match error.kind() {
std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
Self::FileName => StatusCode::INTERNAL_SERVER_ERROR,
// TODO: Improve
Self::Sqlx(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
Response::builder()
.status(status)
.body(Body::empty())
.unwrap()
}
}

38
backend/src/fs/dir.rs Normal file
View File

@@ -0,0 +1,38 @@
use std::path::Path;
use tokio::fs;
use crate::{Result, error::AppError};
use super::{DirectoryEntry, FileType};
pub async fn get_dir_entries<P>(path: P) -> Result<Vec<DirectoryEntry>>
where
P: AsRef<Path>,
{
let mut dir = fs::read_dir(path).await?;
let mut files = Vec::new();
while let Ok(Some(entry)) = dir.next_entry().await {
let name = entry
.file_name()
.into_string()
.map_err(|_| AppError::FileName)?;
let file_type = {
let file_type = entry.file_type().await?;
if file_type.is_dir() {
FileType::Directory
} else if file_type.is_file() {
FileType::File
} else {
continue;
}
};
files.push(DirectoryEntry::new(name, file_type));
}
Ok(files)
}

24
backend/src/fs/mod.rs Normal file
View File

@@ -0,0 +1,24 @@
mod dir;
pub use dir::*;
use serde::Serialize;
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FileType {
File,
Directory,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryEntry {
name: String,
file_type: FileType,
}
impl DirectoryEntry {
pub fn new(name: String, file_type: FileType) -> Self {
Self { name, file_type }
}
}

View File

@@ -1,7 +1,17 @@
use db::get_postgres_pool;
use error::AppError;
pub mod api;
pub mod db;
pub mod error;
pub mod fs;
mod server; mod server;
pub mod warrens;
pub type Result<T> = std::result::Result<T, AppError>;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> std::result::Result<(), AppError> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
env_logger::builder() env_logger::builder()
@@ -11,7 +21,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.parse_default_env() .parse_default_env()
.init(); .init();
server::start().await?; let pool = get_postgres_pool().await?;
server::start(pool).await?;
Ok(()) Ok(())
} }

View File

@@ -1,11 +1,32 @@
use std::{env, error::Error}; use std::env;
use axum::Router; use axum::http::HeaderValue;
use sqlx::{Pool, Postgres};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::services::ServeDir; use tower_http::{
cors::{self, CorsLayer},
services::ServeDir,
};
pub(super) async fn start() -> Result<(), Box<dyn Error>> { use crate::{
let mut app = Router::new(); Result,
api::{self, AppState},
};
pub type Router = axum::Router<AppState>;
pub(super) async fn start(pool: Pool<Postgres>) -> Result<()> {
let cors_origin = env::var("CORS_ALLOW_ORIGIN").unwrap_or("*".to_string());
let serve_dir = std::env::var("SERVE_DIRECTORY")?;
let mut app = Router::new()
.nest("/api", api::router())
.layer(
CorsLayer::new().allow_origin(cors::AllowOrigin::exact(HeaderValue::from_str(
&cors_origin,
)?)),
)
.with_state(AppState::new(pool, serve_dir));
if !cfg!(debug_assertions) { if !cfg!(debug_assertions) {
let frontend_path = env::var("STATIC_FRONTEND_DIR").unwrap_or("./frontend".to_string()); let frontend_path = env::var("STATIC_FRONTEND_DIR").unwrap_or("./frontend".to_string());

49
backend/src/warrens/db.rs Normal file
View File

@@ -0,0 +1,49 @@
use sqlx::{Acquire, PgExecutor, Postgres};
use uuid::Uuid;
use crate::Result;
use super::Warren;
impl Warren {
pub async fn get<'c, E>(e: E, id: &Uuid) -> Result<Self>
where
E: PgExecutor<'c> + Acquire<'c, Database = Postgres>,
{
let warren: Self = sqlx::query_as(
"
SELECT
*
FROM
warrens
WHERE
id = $1
",
)
.bind(id)
.fetch_one(e)
.await?;
Ok(warren)
}
pub async fn list<'c, E>(e: E) -> Result<Vec<Self>>
where
E: PgExecutor<'c> + Acquire<'c, Database = Postgres>,
{
let warren: Vec<Self> = sqlx::query_as(
"
SELECT
*
FROM
warrens
LIMIT
50
",
)
.fetch_all(e)
.await?;
Ok(warren)
}
}

View File

@@ -0,0 +1,53 @@
pub mod db;
use std::path::PathBuf;
use serde::Serialize;
use sqlx::prelude::FromRow;
use uuid::Uuid;
use crate::{
Result,
fs::{DirectoryEntry, get_dir_entries},
};
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Warren {
id: Uuid,
name: String,
#[serde(skip)]
path: String,
}
impl Warren {
pub fn id(&self) -> &Uuid {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &str {
&self.path
}
}
impl Warren {
pub async fn read_path(
&self,
serve_path: &str,
path: Option<String>,
) -> Result<Vec<DirectoryEntry>> {
let mut final_path = PathBuf::from(serve_path);
final_path.push(self.path.strip_prefix("/").unwrap_or(&self.path));
if let Some(ref rest) = path {
final_path.push(rest);
}
get_dir_entries(final_path).await
}
}

View File

@@ -1,14 +1,39 @@
services: services:
warren: warren:
depends_on:
- 'postgres'
image: 'warren:latest' image: 'warren:latest'
container_name: 'warren' container_name: 'warren'
build: '.' build: '.'
ports:
- '8081:8080'
restart: 'unless-stopped'
networks:
- 'warren-net'
environment:
- 'BIND_ADDRESS=0.0.0.0:8080'
- 'POSTGRES_HOST=warren-postgres'
- 'POSTGRES_PORT=5432'
- 'POSTGRES_USER=postgres'
- 'POSTGRES_PASSWORD=pg'
- 'POSTGRES_DATABASE=warren'
- 'SERVE_DIRECTORY=/serve'
- 'CORS_ALLOW_ORIGIN=http://localhost:3000'
volumes:
- './backend/serve:/serve:rw'
postgres: postgres:
image: 'postgres:17' image: 'postgres:17'
container_name: 'warren-db' container_name: 'warren-db'
hostname: 'warren-postgres'
networks:
- 'warren-net'
volumes: volumes:
- './postgres-data:/var/lib/postgresql/data' - './postgres-data:/var/lib/postgresql/data'
environment: environment:
- 'POSTGRES_PASSWORD=pg' - 'POSTGRES_PASSWORD=pg'
ports: ports:
- '5432:5432/tcp' - '5432:5432/tcp'
networks:
warren-net:
name: 'warren-net'
external: false

3
frontend/.env.dev Normal file
View File

@@ -0,0 +1,3 @@
# this file is ignored when the app is built since we're using SSG
NUXT_PUBLIC_API_BASE="http://127.0.0.1:8080/api"

1
frontend/.gitignore vendored
View File

@@ -22,3 +22,4 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.dev

View File

@@ -1,3 +1,11 @@
<script setup lang="ts">
import { getWarrens } from './lib/api/warrens';
const store = useWarrenStore();
store.warrens = await getWarrens();
</script>
<template> <template>
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<NuxtLoadingIndicator /> <NuxtLoadingIndicator />

View File

@@ -9,6 +9,7 @@
"@nuxt/icon": "1.15.0", "@nuxt/icon": "1.15.0",
"@nuxt/image": "1.10.0", "@nuxt/image": "1.10.0",
"@nuxt/test-utils": "3.19.2", "@nuxt/test-utils": "3.19.2",
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0", "@vueuse/core": "^13.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -16,6 +17,7 @@
"eslint": "^9.0.0", "eslint": "^9.0.0",
"lucide-vue-next": "^0.525.0", "lucide-vue-next": "^0.525.0",
"nuxt": "^3.17.6", "nuxt": "^3.17.6",
"pinia": "^3.0.3",
"reka-ui": "^2.3.2", "reka-ui": "^2.3.2",
"shadcn-nuxt": "2.2.0", "shadcn-nuxt": "2.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -369,6 +371,8 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@pinia/nuxt": ["@pinia/nuxt@0.11.1", "", { "dependencies": { "@nuxt/kit": "^3.9.0" }, "peerDependencies": { "pinia": "^3.0.3" } }, "sha512-tCD8ioWhhIHKwm8Y9VvyhBAV/kK4W5uGBIYbI5iM4N1t7duOqK6ECBUavrMxMolELayqqMLb9+evegrh3S7s2A=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@@ -547,7 +551,7 @@
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/shared": "3.5.17" } }, "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ=="], "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/shared": "3.5.17" } }, "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ=="],
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], "@vue/devtools-api": ["@vue/devtools-api@7.7.7", "", { "dependencies": { "@vue/devtools-kit": "^7.7.7" } }, "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg=="],
"@vue/devtools-core": ["@vue/devtools-core@7.7.7", "", { "dependencies": { "@vue/devtools-kit": "^7.7.7", "@vue/devtools-shared": "^7.7.7", "mitt": "^3.0.1", "nanoid": "^5.1.0", "pathe": "^2.0.3", "vite-hot-client": "^2.0.4" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-9z9TLbfC+AjAi1PQyWX+OErjIaJmdFlbDHcD+cAMYKY6Bh5VlsAtCeGyRMrXwIlMEQPukvnWt3gZBLwTAIMKzQ=="], "@vue/devtools-core": ["@vue/devtools-core@7.7.7", "", { "dependencies": { "@vue/devtools-kit": "^7.7.7", "@vue/devtools-shared": "^7.7.7", "mitt": "^3.0.1", "nanoid": "^5.1.0", "pathe": "^2.0.3", "vite-hot-client": "^2.0.4" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-9z9TLbfC+AjAi1PQyWX+OErjIaJmdFlbDHcD+cAMYKY6Bh5VlsAtCeGyRMrXwIlMEQPukvnWt3gZBLwTAIMKzQ=="],
@@ -1445,6 +1449,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pinia": ["pinia@3.0.3", "", { "dependencies": { "@vue/devtools-api": "^7.7.2" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
@@ -2199,6 +2205,8 @@
"vite-plugin-vue-tracer/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "vite-plugin-vue-tracer/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"winston/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "winston/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"winston/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "winston/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],

View File

@@ -14,18 +14,7 @@ import {
const route = useRoute(); const route = useRoute();
const warrens = [ const store = useWarrenStore();
{
title: 'Thyr',
url: '/warrens/Thyr',
icon: h(Icon, { name: 'lucide:folder-root' }),
},
{
title: 'Serc',
url: '/warrens/Serc',
icon: h(Icon, { name: 'lucide:folder-root' }),
},
];
</script> </script>
<template> <template>
@@ -57,22 +46,24 @@ const warrens = [
<CollapsibleContent> <CollapsibleContent>
<SidebarMenuSub> <SidebarMenuSub>
<SidebarMenuSubItem <SidebarMenuSubItem
v-for="warren in warrens" v-for="(warren, uuid) in store.warrens"
:key="warren.title" :key="uuid"
> >
<SidebarMenuSubButton <SidebarMenuSubButton
as-child as-child
:tooltip="warren.title" :tooltip="warren.name"
:is-active=" :is-active="
warren.url === route.path route.path.startsWith(
`/warrens/${uuid}`
)
" "
class="transition-all" class="transition-all"
> >
<NuxtLink :to="warren.url"> <NuxtLink :to="`/warrens/${uuid}`">
<component <Icon
:is="warren.icon" name="lucide:folder-root"
></component> />
<span>{{ warren.title }}</span> <span>{{ warren.name }}</span>
</NuxtLink> </NuxtLink>
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>

View File

@@ -1,24 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DirectoryEntryType } from '~/types'; import type { FileType } from '~/types';
const route = useRoute(); const route = useRoute();
const { name, entryType } = defineProps<{ const { name, entryType, disabled } = defineProps<{
name: string; name: string;
entryType: DirectoryEntryType; entryType: FileType;
disabled: boolean,
}>(); }>();
const iconName = entryType === 'file' ? 'lucide:file' : 'lucide:folder'; const iconName = entryType === 'file' ? 'lucide:file' : 'lucide:folder';
</script> </script>
<template> <template>
<Button class="w-36 h-12" variant="outline" size="lg">
<NuxtLink <NuxtLink
class="flex flex-row items-center gap-1.5"
:to="joinPaths(route.path, name)" :to="joinPaths(route.path, name)"
:class="['select-none', { 'pointer-events-none': disabled }]"
>
<Button
class="w-44 h-12"
variant="outline"
size="lg"
:disabled="disabled"
> >
<Icon :name="iconName" /> <Icon :name="iconName" />
<span>{{ name }}</span> <span class="truncate">{{ name }}</span>
</NuxtLink>
</Button> </Button>
</NuxtLink>
</template> </template>

View File

@@ -1,34 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import type { DirectoryEntryType } from '~/types'; import type { DirectoryEntry } from '~/types';
const items: Array<{ name: string; entryType: DirectoryEntryType }> = [ const { entries } = defineProps<{
/* { entries: DirectoryEntry[];
name: 'File A', }>();
entryType: 'file',
}, const { isLoading } = useLoadingIndicator();
{
name: 'File B',
entryType: 'file',
}, */
{
name: 'Directory A',
entryType: 'directory',
},
{
name: 'Directory B',
entryType: 'directory',
},
];
</script> </script>
<template> <template>
<ScrollArea class="w-full h-full"> <ScrollArea class="w-full h-full">
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<DirectoryEntry <DirectoryEntry
v-for="item in items" v-for="entry in entries"
:key="item.name" :key="entry.name"
:name="item.name" :name="entry.name"
:entry-type="item.entryType" :entry-type="entry.fileType"
:disabled="isLoading"
/> />
</div> </div>
</ScrollArea> </ScrollArea>

View File

@@ -0,0 +1,29 @@
import type { DirectoryEntry } from '~/types';
import type { Warren } from '~/types/warrens';
export async function getWarrens(): Promise<Record<string, Warren>> {
const arr: Warren[] = await $fetch(getApiUrl('warrens'), {
method: 'GET',
});
const warrens: Record<string, Warren> = {};
for (const warren of arr) {
warrens[warren.id] = warren;
}
return warrens;
}
export async function getWarrenDirectory(
path: string
): Promise<DirectoryEntry[]> {
const entries: DirectoryEntry[] = await $fetch(
getApiUrl(`warrens/${path}`),
{
method: 'GET',
}
);
return entries;
}

View File

@@ -13,6 +13,7 @@ export default defineNuxtConfig({
'@nuxt/test-utils', '@nuxt/test-utils',
'shadcn-nuxt', 'shadcn-nuxt',
'@nuxtjs/color-mode', '@nuxtjs/color-mode',
'@pinia/nuxt',
], ],
css: ['~/assets/css/tailwind.css'], css: ['~/assets/css/tailwind.css'],
@@ -53,4 +54,10 @@ export default defineNuxtConfig({
}, },
ssr: false, ssr: false,
runtimeConfig: {
public: {
apiBase: '/api',
},
},
}); });

View File

@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev --dotenv .env.dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
@@ -16,6 +16,7 @@
"@nuxt/icon": "1.15.0", "@nuxt/icon": "1.15.0",
"@nuxt/image": "1.10.0", "@nuxt/image": "1.10.0",
"@nuxt/test-utils": "3.19.2", "@nuxt/test-utils": "3.19.2",
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0", "@vueuse/core": "^13.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -23,6 +24,7 @@
"eslint": "^9.0.0", "eslint": "^9.0.0",
"lucide-vue-next": "^0.525.0", "lucide-vue-next": "^0.525.0",
"nuxt": "^3.17.6", "nuxt": "^3.17.6",
"pinia": "^3.0.3",
"reka-ui": "^2.3.2", "reka-ui": "^2.3.2",
"shadcn-nuxt": "2.2.0", "shadcn-nuxt": "2.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",

View File

@@ -1,3 +1,9 @@
<script setup lang="ts">
import { getWarrenDirectory } from '~/lib/api/warrens';
const route = useRoute();
const entries = await getWarrenDirectory(route.path.split('/warrens/')[1]);
</script>
<template> <template>
<DirectoryList /> <DirectoryList :entries="entries" />
</template> </template>

View File

@@ -1,28 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
const warrens = ['Thyr', 'Serc']; const store = useWarrenStore();
</script> </script>
<template> <template>
<ScrollArea class="w-full h-full"> <ScrollArea class="w-full h-full">
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Button
v-for="(warren, i) in warrens"
:key="i"
class="w-36 h-12"
variant="outline"
size="lg"
as-child
>
<NuxtLink <NuxtLink
class="flex flex-row items-center gap-1.5" v-for="(warren, uuid) in store.warrens"
:to="`/warrens/${warren}`" :key="uuid"
:to="`/warrens/${uuid}`"
> >
<Button class="w-44 h-12" variant="outline" size="lg">
<Icon name="lucide:folder-root" /> <Icon name="lucide:folder-root" />
{{ warren }} <span clas="truncate">{{ warren.name }}</span>
</NuxtLink>
</Button> </Button>
</NuxtLink>
</div> </div>
</ScrollArea> </ScrollArea>
</template> </template>

8
frontend/stores/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineStore } from 'pinia';
import type { Warren } from '~/types/warrens';
export const useWarrenStore = defineStore('warrens', {
state: () => ({
warrens: {} as Record<string, Warren>,
}),
});

View File

@@ -1,6 +1,11 @@
export type DirectoryEntryType = 'file' | 'directory'; export type FileType = 'file' | 'directory';
export type BreadcrumbData = { export type BreadcrumbData = {
name: string; name: string;
href: string; href: string;
}; };
export type DirectoryEntry = {
name: string;
fileType: FileType;
};

View File

@@ -0,0 +1,4 @@
export type Warren = {
id: string,
name: string,
};

5
frontend/utils/api.ts Normal file
View File

@@ -0,0 +1,5 @@
export function getApiUrl(path: string): string {
const API_BASE_URL = useRuntimeConfig().public.apiBase;
console.log(API_BASE_URL);
return `${API_BASE_URL}/${path}`;
}

View File

@@ -1,6 +1,8 @@
import type { BreadcrumbData } from '~/types'; import type { BreadcrumbData } from '~/types';
export function getBreadcrumbs(path: string): BreadcrumbData[] { export function getBreadcrumbs(path: string): BreadcrumbData[] {
const { warrens } = useWarrenStore();
const crumbs = path const crumbs = path
.split('/') .split('/')
.filter((v) => v.length > 0) .filter((v) => v.length > 0)
@@ -21,6 +23,14 @@ export function getBreadcrumbs(path: string): BreadcrumbData[] {
.join('/'); .join('/');
} }
if (
crumbs.length >= 3 &&
crumbs[1].href === '/warrens' &&
crumbs[2].name in warrens
) {
crumbs[2].name = warrens[crumbs[2].name].name;
}
return crumbs; return crumbs;
} }