create dirs + delete dirs + delete files
This commit is contained in:
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8.4"
|
axum = { version = "0.8.4", features = ["query"] }
|
||||||
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"
|
||||||
|
|||||||
24
backend/src/api/warrens/create_directory.rs
Normal file
24
backend/src/api/warrens/create_directory.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Result, api::AppState, warrens::Warren};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct CreateWarrenDirectoryPath {
|
||||||
|
warren_id: Uuid,
|
||||||
|
rest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn route(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(path): Path<CreateWarrenDirectoryPath>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let warren = Warren::get(state.pool(), &path.warren_id).await?;
|
||||||
|
|
||||||
|
warren
|
||||||
|
.create_directory(state.serve_dir(), path.rest)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
31
backend/src/api/warrens/delete_directory.rs
Normal file
31
backend/src/api/warrens/delete_directory.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Result, api::AppState, fs::FileType, warrens::Warren};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct DeleteWarrenDirectoryPath {
|
||||||
|
warren_id: Uuid,
|
||||||
|
rest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteWarrenParams {
|
||||||
|
file_type: FileType,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn route(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(path): Path<DeleteWarrenDirectoryPath>,
|
||||||
|
Query(DeleteWarrenParams { file_type }): Query<DeleteWarrenParams>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let warren = Warren::get(state.pool(), &path.warren_id).await?;
|
||||||
|
|
||||||
|
warren
|
||||||
|
.delete_entry(state.serve_dir(), path.rest, file_type)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
mod create_directory;
|
||||||
|
mod delete_directory;
|
||||||
mod get_warren_path;
|
mod get_warren_path;
|
||||||
mod list_warrens;
|
mod list_warrens;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::{delete, get, post};
|
||||||
|
|
||||||
use crate::server::Router;
|
use crate::server::Router;
|
||||||
|
|
||||||
@@ -10,4 +12,6 @@ pub(super) fn router() -> Router {
|
|||||||
.route("/", get(list_warrens::route))
|
.route("/", get(list_warrens::route))
|
||||||
.route("/{warren_id}", get(get_warren_path::route))
|
.route("/{warren_id}", get(get_warren_path::route))
|
||||||
.route("/{warren_id}/{*rest}", get(get_warren_path::route))
|
.route("/{warren_id}/{*rest}", get(get_warren_path::route))
|
||||||
|
.route("/{warren_id}/{*rest}", post(create_directory::route))
|
||||||
|
.route("/{warren_id}/{*rest}", delete(delete_directory::route))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,3 +36,21 @@ where
|
|||||||
|
|
||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_dir<P>(path: P) -> Result<()>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
fs::create_dir(path).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_dir<P>(path: P) -> Result<()>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
fs::remove_dir_all(path).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
14
backend/src/fs/file.rs
Normal file
14
backend/src/fs/file.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
pub async fn delete_file<P>(path: P) -> Result<()>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
fs::remove_file(path).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
mod dir;
|
mod dir;
|
||||||
|
mod file;
|
||||||
pub use dir::*;
|
pub use dir::*;
|
||||||
|
pub use file::*;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum FileType {
|
pub enum FileType {
|
||||||
File,
|
File,
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ pub(super) async fn start(pool: Pool<Postgres>) -> Result<()> {
|
|||||||
let mut app = Router::new()
|
let mut app = Router::new()
|
||||||
.nest("/api", api::router())
|
.nest("/api", api::router())
|
||||||
.layer(
|
.layer(
|
||||||
CorsLayer::new().allow_origin(cors::AllowOrigin::exact(HeaderValue::from_str(
|
CorsLayer::new()
|
||||||
&cors_origin,
|
.allow_origin(cors::AllowOrigin::exact(HeaderValue::from_str(
|
||||||
)?)),
|
&cors_origin,
|
||||||
|
)?))
|
||||||
|
.allow_methods(cors::Any),
|
||||||
)
|
)
|
||||||
.with_state(AppState::new(pool, serve_dir));
|
.with_state(AppState::new(pool, serve_dir));
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Result,
|
Result,
|
||||||
fs::{DirectoryEntry, get_dir_entries},
|
fs::{DirectoryEntry, FileType, create_dir, delete_dir, delete_file, get_dir_entries},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
@@ -40,14 +40,40 @@ impl Warren {
|
|||||||
serve_path: &str,
|
serve_path: &str,
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
) -> Result<Vec<DirectoryEntry>> {
|
) -> Result<Vec<DirectoryEntry>> {
|
||||||
let mut final_path = PathBuf::from(serve_path);
|
let path = build_path(serve_path, &self.path, path.as_deref());
|
||||||
|
|
||||||
final_path.push(self.path.strip_prefix("/").unwrap_or(&self.path));
|
get_dir_entries(path).await
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref rest) = path {
|
pub async fn create_directory(&self, serve_path: &str, path: String) -> Result<()> {
|
||||||
final_path.push(rest);
|
let path = build_path(serve_path, &self.path, Some(&path));
|
||||||
|
|
||||||
|
create_dir(path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_entry(
|
||||||
|
&self,
|
||||||
|
serve_path: &str,
|
||||||
|
path: String,
|
||||||
|
file_type: FileType,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = build_path(serve_path, &self.path, Some(&path));
|
||||||
|
|
||||||
|
match file_type {
|
||||||
|
FileType::File => delete_file(path).await,
|
||||||
|
FileType::Directory => delete_dir(path).await,
|
||||||
}
|
}
|
||||||
|
|
||||||
get_dir_entries(final_path).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> PathBuf {
|
||||||
|
let mut final_path = PathBuf::from(serve_path);
|
||||||
|
|
||||||
|
final_path.push(warren_path.strip_prefix("/").unwrap_or(warren_path));
|
||||||
|
|
||||||
|
if let Some(ref rest) = rest_path {
|
||||||
|
final_path.push(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
final_path
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import 'vue-sonner/style.css';
|
||||||
|
|
||||||
import { getWarrens } from './lib/api/warrens';
|
import { getWarrens } from './lib/api/warrens';
|
||||||
|
|
||||||
const store = useWarrenStore();
|
const store = useWarrenStore();
|
||||||
@@ -7,6 +10,8 @@ store.warrens = await getWarrens();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<Toaster class="pointer-events-auto" />
|
||||||
|
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<NuxtLoadingIndicator />
|
<NuxtLoadingIndicator />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
|
|||||||
17
frontend/assets/css/sonner.css
Normal file
17
frontend/assets/css/sonner.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@reference './tailwind.css';
|
||||||
|
|
||||||
|
li[data-sonner-toast] > div[data-content] > div[data-description] {
|
||||||
|
@apply text-muted-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
li[data-sonner-toast][data-type='error'] > div[data-icon] {
|
||||||
|
@apply text-destructive-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
li[data-sonner-toast][data-type='error'] > div[data-icon] {
|
||||||
|
@apply text-destructive-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
li[data-sonner-toast][data-type='error'] > div[data-content] > div[data-title] {
|
||||||
|
@apply text-destructive-foreground;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-sonner": "^2.0.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.57",
|
"@iconify-json/lucide": "^1.2.57",
|
||||||
@@ -1861,6 +1862,8 @@
|
|||||||
|
|
||||||
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
||||||
|
|
||||||
|
"vue-sonner": ["vue-sonner@2.0.1", "", {}, "sha512-sn4vjCRzRcnMaxaLa9aNSyZQi6S+gshiea5Lc3eqpkj0ES9LH8ljg+WJCkxefr28V4PZ9xkUXBIWpxGfQxstIg=="],
|
||||||
|
|
||||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const store = useWarrenStore();
|
|||||||
"
|
"
|
||||||
class="transition-all"
|
class="transition-all"
|
||||||
>
|
>
|
||||||
<NuxtLink :to="`/warrens/${uuid}`">
|
<NuxtLink :to="`/warrens/${uuid}`">
|
||||||
<Icon
|
<Icon
|
||||||
name="lucide:folder-root"
|
name="lucide:folder-root"
|
||||||
/>
|
/>
|
||||||
|
|||||||
66
frontend/components/CreateDirectoryDialog.vue
Normal file
66
frontend/components/CreateDirectoryDialog.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { createDirectory } from '~/lib/api/warrens';
|
||||||
|
|
||||||
|
const warrenRoute = useWarrenRoute();
|
||||||
|
|
||||||
|
const creating = ref(false);
|
||||||
|
const open = ref(false);
|
||||||
|
const directoryName = ref('');
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
creating.value = true;
|
||||||
|
|
||||||
|
const { success } = await createDirectory(
|
||||||
|
warrenRoute.value,
|
||||||
|
directoryName.value
|
||||||
|
);
|
||||||
|
|
||||||
|
creating.value = false;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
directoryName.value = '';
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="open">
|
||||||
|
<DialogTrigger as-child>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create a directory</DialogTitle>
|
||||||
|
<DialogDescription
|
||||||
|
>Give your directory a memorable name</DialogDescription
|
||||||
|
>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="directoryName"
|
||||||
|
type="text"
|
||||||
|
name="directory-name"
|
||||||
|
placeholder="my-awesome-directory"
|
||||||
|
minlength="20"
|
||||||
|
maxlength="30"
|
||||||
|
aria-required="true"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button :disabled="creating" @click="submit">Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -1,30 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
|
import { deleteWarrenEntry } from '~/lib/api/warrens';
|
||||||
import type { FileType } from '~/types';
|
import type { FileType } from '~/types';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const warrenRoute = useWarrenRoute();
|
||||||
|
|
||||||
const { name, entryType, disabled } = defineProps<{
|
const { name, fileType, disabled } = defineProps<{
|
||||||
name: string;
|
name: string;
|
||||||
entryType: FileType;
|
fileType: FileType;
|
||||||
disabled: boolean,
|
disabled: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const iconName = entryType === 'file' ? 'lucide:file' : 'lucide:folder';
|
const iconName = computed(() =>
|
||||||
|
fileType === 'file' ? 'lucide:file' : 'lucide:folder'
|
||||||
|
);
|
||||||
|
const deleting = ref(false);
|
||||||
|
|
||||||
|
async function submitDelete() {
|
||||||
|
deleting.value = true;
|
||||||
|
|
||||||
|
await deleteWarrenEntry(warrenRoute.value, name, fileType);
|
||||||
|
|
||||||
|
deleting.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<ContextMenu>
|
||||||
:to="joinPaths(route.path, name)"
|
<ContextMenuTrigger>
|
||||||
:class="['select-none', { 'pointer-events-none': disabled }]"
|
<NuxtLink
|
||||||
>
|
:to="joinPaths(route.path, name)"
|
||||||
<Button
|
:class="['select-none', { 'pointer-events-none': disabled }]"
|
||||||
class="w-44 h-12"
|
>
|
||||||
variant="outline"
|
<Button
|
||||||
size="lg"
|
class="w-44 h-12"
|
||||||
:disabled="disabled"
|
variant="outline"
|
||||||
>
|
size="lg"
|
||||||
<Icon :name="iconName" />
|
:disabled="disabled"
|
||||||
<span class="truncate">{{ name }}</span>
|
>
|
||||||
</Button>
|
<Icon :name="iconName" />
|
||||||
</NuxtLink>
|
<span class="truncate">{{ name }}</span>
|
||||||
|
</Button>
|
||||||
|
</NuxtLink>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem @select="submitDelete">
|
||||||
|
<Icon name="lucide:trash-2" />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ const { entries } = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { isLoading } = useLoadingIndicator();
|
const { isLoading } = useLoadingIndicator();
|
||||||
|
|
||||||
|
const sortedEntries = computed(() =>
|
||||||
|
entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
</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 flex-wrap gap-2">
|
||||||
<DirectoryEntry
|
<DirectoryEntry
|
||||||
v-for="entry in entries"
|
v-for="entry in sortedEntries"
|
||||||
:key="entry.name"
|
:key="entry.name"
|
||||||
:name="entry.name"
|
:name="entry.name"
|
||||||
:entry-type="entry.fileType"
|
:file-type="entry.fileType"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
18
frontend/components/ui/context-menu/ContextMenu.vue
Normal file
18
frontend/components/ui/context-menu/ContextMenu.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
|
||||||
|
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuRootProps>()
|
||||||
|
const emits = defineEmits<ContextMenuRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuRoot
|
||||||
|
data-slot="context-menu"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Check } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
type ContextMenuCheckboxItemEmits,
|
||||||
|
type ContextMenuCheckboxItemProps,
|
||||||
|
ContextMenuItemIndicator,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuCheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuItemIndicator>
|
||||||
|
<Check class="size-4" />
|
||||||
|
</ContextMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuCheckboxItem>
|
||||||
|
</template>
|
||||||
34
frontend/components/ui/context-menu/ContextMenuContent.vue
Normal file
34
frontend/components/ui/context-menu/ContextMenuContent.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ContextMenuContent,
|
||||||
|
type ContextMenuContentEmits,
|
||||||
|
type ContextMenuContentProps,
|
||||||
|
ContextMenuPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<ContextMenuContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuPortal>
|
||||||
|
<ContextMenuContent
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-context-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenuPortal>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/context-menu/ContextMenuGroup.vue
Normal file
14
frontend/components/ui/context-menu/ContextMenuGroup.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ContextMenuGroup, type ContextMenuGroupProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuGroupProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuGroup
|
||||||
|
data-slot="context-menu-group"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuGroup>
|
||||||
|
</template>
|
||||||
39
frontend/components/ui/context-menu/ContextMenuItem.vue
Normal file
39
frontend/components/ui/context-menu/ContextMenuItem.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ContextMenuItem,
|
||||||
|
type ContextMenuItemEmits,
|
||||||
|
type ContextMenuItemProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ContextMenuItemProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
inset?: boolean
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
}>(), {
|
||||||
|
variant: 'default',
|
||||||
|
})
|
||||||
|
const emits = defineEmits<ContextMenuItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuItem
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
:data-inset="inset ? '' : undefined"
|
||||||
|
:data-variant="variant"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
`focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuItem>
|
||||||
|
</template>
|
||||||
21
frontend/components/ui/context-menu/ContextMenuLabel.vue
Normal file
21
frontend/components/ui/context-menu/ContextMenuLabel.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ContextMenuLabel, type ContextMenuLabelProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuLabel
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
:data-inset="inset ? '' : undefined"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuLabel>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/context-menu/ContextMenuPortal.vue
Normal file
14
frontend/components/ui/context-menu/ContextMenuPortal.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ContextMenuPortal, type ContextMenuPortalProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuPortalProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuPortal
|
||||||
|
data-slot="context-menu-portal"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuPortal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
type ContextMenuRadioGroupEmits,
|
||||||
|
type ContextMenuRadioGroupProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuRadioGroupProps>()
|
||||||
|
const emits = defineEmits<ContextMenuRadioGroupEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuRadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuRadioGroup>
|
||||||
|
</template>
|
||||||
38
frontend/components/ui/context-menu/ContextMenuRadioItem.vue
Normal file
38
frontend/components/ui/context-menu/ContextMenuRadioItem.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Circle } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
ContextMenuItemIndicator,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
type ContextMenuRadioItemEmits,
|
||||||
|
type ContextMenuRadioItemProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<ContextMenuRadioItemEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuRadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(
|
||||||
|
`focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuItemIndicator>
|
||||||
|
<Circle class="size-2 fill-current" />
|
||||||
|
</ContextMenuItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuRadioItem>
|
||||||
|
</template>
|
||||||
21
frontend/components/ui/context-menu/ContextMenuSeparator.vue
Normal file
21
frontend/components/ui/context-menu/ContextMenuSeparator.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ContextMenuSeparator,
|
||||||
|
type ContextMenuSeparatorProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSeparator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
17
frontend/components/ui/context-menu/ContextMenuShortcut.vue
Normal file
17
frontend/components/ui/context-menu/ContextMenuShortcut.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/context-menu/ContextMenuSub.vue
Normal file
22
frontend/components/ui/context-menu/ContextMenuSub.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ContextMenuSub,
|
||||||
|
type ContextMenuSubEmits,
|
||||||
|
type ContextMenuSubProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuSubProps>()
|
||||||
|
const emits = defineEmits<ContextMenuSubEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSub
|
||||||
|
data-slot="context-menu-sub"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuSub>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ContextMenuSubContent,
|
||||||
|
type DropdownMenuSubContentEmits,
|
||||||
|
type DropdownMenuSubContentProps,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
type ContextMenuSubTriggerProps,
|
||||||
|
useForwardProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuSubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
:data-inset="inset ? '' : undefined"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
`focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<ChevronRight class="ml-auto" />
|
||||||
|
</ContextMenuSubTrigger>
|
||||||
|
</template>
|
||||||
16
frontend/components/ui/context-menu/ContextMenuTrigger.vue
Normal file
16
frontend/components/ui/context-menu/ContextMenuTrigger.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ContextMenuTrigger, type ContextMenuTriggerProps, useForwardProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<ContextMenuTriggerProps>()
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(props)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContextMenuTrigger
|
||||||
|
data-slot="context-menu-trigger"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/context-menu/index.ts
Normal file
14
frontend/components/ui/context-menu/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export { default as ContextMenu } from './ContextMenu.vue'
|
||||||
|
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'
|
||||||
|
export { default as ContextMenuContent } from './ContextMenuContent.vue'
|
||||||
|
export { default as ContextMenuGroup } from './ContextMenuGroup.vue'
|
||||||
|
export { default as ContextMenuItem } from './ContextMenuItem.vue'
|
||||||
|
export { default as ContextMenuLabel } from './ContextMenuLabel.vue'
|
||||||
|
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'
|
||||||
|
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'
|
||||||
|
export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'
|
||||||
|
export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'
|
||||||
|
export { default as ContextMenuSub } from './ContextMenuSub.vue'
|
||||||
|
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
|
||||||
|
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'
|
||||||
|
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'
|
||||||
17
frontend/components/ui/dialog/Dialog.vue
Normal file
17
frontend/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
data-slot="dialog"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/dialog/DialogClose.vue
Normal file
14
frontend/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogClose, type DialogCloseProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="dialog-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
46
frontend/components/ui/dialog/DialogContent.vue
Normal file
46
frontend/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
type DialogContentEmits,
|
||||||
|
type DialogContentProps,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import DialogOverlay from './DialogOverlay.vue'
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="dialog-content"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/dialog/DialogDescription.vue
Normal file
22
frontend/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="dialog-description"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
15
frontend/components/ui/dialog/DialogFooter.vue
Normal file
15
frontend/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
frontend/components/ui/dialog/DialogHeader.vue
Normal file
17
frontend/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
20
frontend/components/ui/dialog/DialogOverlay.vue
Normal file
20
frontend/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogOverlay, type DialogOverlayProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
56
frontend/components/ui/dialog/DialogScrollContent.vue
Normal file
56
frontend/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
type DialogContentEmits,
|
||||||
|
type DialogContentProps,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="forwarded"
|
||||||
|
@pointer-down-outside="(event) => {
|
||||||
|
const originalEvent = event.detail.originalEvent;
|
||||||
|
const target = originalEvent.target as HTMLElement;
|
||||||
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogOverlay>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
22
frontend/components/ui/dialog/DialogTitle.vue
Normal file
22
frontend/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="dialog-title"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
14
frontend/components/ui/dialog/DialogTrigger.vue
Normal file
14
frontend/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
10
frontend/components/ui/dialog/index.ts
Normal file
10
frontend/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { default as Dialog } from './Dialog.vue'
|
||||||
|
export { default as DialogClose } from './DialogClose.vue'
|
||||||
|
export { default as DialogContent } from './DialogContent.vue'
|
||||||
|
export { default as DialogDescription } from './DialogDescription.vue'
|
||||||
|
export { default as DialogFooter } from './DialogFooter.vue'
|
||||||
|
export { default as DialogHeader } from './DialogHeader.vue'
|
||||||
|
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||||
|
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||||
|
export { default as DialogTitle } from './DialogTitle.vue'
|
||||||
|
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||||
18
frontend/components/ui/sonner/Sonner.vue
Normal file
18
frontend/components/ui/sonner/Sonner.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from 'vue-sonner'
|
||||||
|
|
||||||
|
const props = defineProps<ToasterProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Sonner
|
||||||
|
class="toaster group"
|
||||||
|
v-bind="props"
|
||||||
|
:style="{
|
||||||
|
'--normal-bg': 'var(--popover)',
|
||||||
|
'--normal-text': 'var(--popover-foreground)',
|
||||||
|
'--normal-border': 'var(--border)',
|
||||||
|
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
1
frontend/components/ui/sonner/index.ts
Normal file
1
frontend/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Toaster } from './Sonner.vue'
|
||||||
@@ -20,7 +20,7 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
|||||||
<header
|
<header
|
||||||
class="h-16 flex items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"
|
class="h-16 flex items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"
|
||||||
>
|
>
|
||||||
<div class="items-center flex gap-2 px-4">
|
<div class="items-center flex gap-2 px-4 w-full">
|
||||||
<SidebarTrigger class="[&_svg]:size-4" />
|
<SidebarTrigger class="[&_svg]:size-4" />
|
||||||
<Separator orientation="vertical" class="mr-2 !h-4" />
|
<Separator orientation="vertical" class="mr-2 !h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
@@ -50,6 +50,14 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
|||||||
</template>
|
</template>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<div class="ml-auto">
|
||||||
|
<CreateDirectoryDialog>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Icon name="lucide:folder-plus" />
|
||||||
|
</Button>
|
||||||
|
</CreateDirectoryDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import type { DirectoryEntry } from '~/types';
|
import { toast } from 'vue-sonner';
|
||||||
|
import type { DirectoryEntry, FileType } from '~/types';
|
||||||
import type { Warren } from '~/types/warrens';
|
import type { Warren } from '~/types/warrens';
|
||||||
|
|
||||||
export async function getWarrens(): Promise<Record<string, Warren>> {
|
export async function getWarrens(): Promise<Record<string, Warren>> {
|
||||||
const arr: Warren[] = await $fetch(getApiUrl('warrens'), {
|
const { data: arr, error } = await useFetch<Warren[]>(
|
||||||
method: 'GET',
|
getApiUrl('warrens'),
|
||||||
});
|
{
|
||||||
|
method: 'GET',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (arr.value == null) {
|
||||||
|
throw error.value?.name;
|
||||||
|
}
|
||||||
|
|
||||||
const warrens: Record<string, Warren> = {};
|
const warrens: Record<string, Warren> = {};
|
||||||
|
|
||||||
for (const warren of arr) {
|
for (const warren of arr.value) {
|
||||||
warrens[warren.id] = warren;
|
warrens[warren.id] = warren;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,12 +26,76 @@ export async function getWarrens(): Promise<Record<string, Warren>> {
|
|||||||
export async function getWarrenDirectory(
|
export async function getWarrenDirectory(
|
||||||
path: string
|
path: string
|
||||||
): Promise<DirectoryEntry[]> {
|
): Promise<DirectoryEntry[]> {
|
||||||
const entries: DirectoryEntry[] = await $fetch(
|
const { data: entries, error } = await useFetch<DirectoryEntry[]>(
|
||||||
getApiUrl(`warrens/${path}`),
|
getApiUrl(`warrens/${path}`),
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return entries;
|
if (entries.value == null) {
|
||||||
|
throw error.value?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDirectory(
|
||||||
|
path: string,
|
||||||
|
directoryName: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const { status } = await useFetch(
|
||||||
|
getApiUrl(`warrens/${path}/${directoryName}`),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status.value !== 'success') {
|
||||||
|
toast.error('Directory', {
|
||||||
|
id: 'CREATE_DIRECTORY_TOAST',
|
||||||
|
description: `Failed to create directory`,
|
||||||
|
});
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshNuxtData('current-directory');
|
||||||
|
|
||||||
|
toast.success('Directory', {
|
||||||
|
id: 'CREATE_DIRECTORY_TOAST',
|
||||||
|
description: `Successfully created directory: ${directoryName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWarrenEntry(
|
||||||
|
path: string,
|
||||||
|
directoryName: string,
|
||||||
|
fileType: FileType
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const { status } = await useFetch(
|
||||||
|
getApiUrl(`warrens/${path}/${directoryName}?fileType=${fileType}`),
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const toastTitle = fileType.slice(0, 1).toUpperCase() + fileType.slice(1);
|
||||||
|
|
||||||
|
if (status.value !== 'success') {
|
||||||
|
toast.error(toastTitle, {
|
||||||
|
id: 'DELETE_DIRECTORY_TOAST',
|
||||||
|
description: `Failed to delete ${fileType}`,
|
||||||
|
});
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshNuxtData('current-directory');
|
||||||
|
|
||||||
|
toast.success(toastTitle, {
|
||||||
|
id: 'DELETE_DIRECTORY_TOAST',
|
||||||
|
description: `Successfully deleted ${fileType}: ${directoryName}`,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineNuxtConfig({
|
|||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
],
|
],
|
||||||
|
|
||||||
css: ['~/assets/css/tailwind.css'],
|
css: ['~/assets/css/tailwind.css', '~/assets/css/sonner.css'],
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
@@ -55,9 +55,9 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
apiBase: '/api',
|
apiBase: '/api',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1",
|
||||||
|
"vue-sonner": "^2.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.57",
|
"@iconify-json/lucide": "^1.2.57",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getWarrenDirectory } from '~/lib/api/warrens';
|
import { getWarrenDirectory } from '~/lib/api/warrens';
|
||||||
const route = useRoute();
|
const entries = useAsyncData('current-directory', () =>
|
||||||
const entries = await getWarrenDirectory(route.path.split('/warrens/')[1]);
|
getWarrenDirectory(useWarrenRoute().value)
|
||||||
|
).data;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DirectoryList :entries="entries" />
|
<div>
|
||||||
|
<DirectoryList v-if="entries != null" :entries="entries" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ export const useWarrenStore = defineStore('warrens', {
|
|||||||
warrens: {} as Record<string, Warren>,
|
warrens: {} as Record<string, Warren>,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useWarrenRoute = () =>
|
||||||
|
computed(() => useRoute().path.split('/warrens/')[1]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Warren = {
|
export type Warren = {
|
||||||
id: string,
|
id: string;
|
||||||
name: string,
|
name: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export function getApiUrl(path: string): string {
|
export function getApiUrl(path: string): string {
|
||||||
const API_BASE_URL = useRuntimeConfig().public.apiBase;
|
const API_BASE_URL = useRuntimeConfig().public.apiBase;
|
||||||
return `${API_BASE_URL}/${path}`;
|
return `${API_BASE_URL}/${path}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user