You've already forked obsidian-visualiser
Migration to Nuxt v4 file structure and dependencies update
This commit is contained in:
251
app/app.vue
Normal file
251
app/app.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
|
||||
<NuxtRouteAnnouncer/>
|
||||
<TooltipProvider>
|
||||
<NuxtLayout>
|
||||
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full max-w-full relative" id="mainContainer">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Content } from '#shared/content.util';
|
||||
import * as Floating from '#shared/floating.util';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
onBeforeMount(() => {
|
||||
Content.init();
|
||||
Floating.init();
|
||||
Toaster.init();
|
||||
|
||||
const unmount = useRouter().afterEach((to, from, failure) => {
|
||||
if(failure) return;
|
||||
|
||||
document.querySelectorAll(`a[href="${from.path}"][data-active]`).forEach(e => e.classList.remove(e.getAttribute('data-active') ?? ''));
|
||||
document.querySelectorAll(`a[href="${to.path}"][data-active]`).forEach(e => e.classList.add(e.getAttribute('data-active') ?? ''));
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
unmount();
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
iconify-icon
|
||||
{
|
||||
display: inline-block;
|
||||
width: attr(width px, 1rem);
|
||||
height: attr(height px, 1rem);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.ToastRoot[data-type='error'] {
|
||||
@apply border-light-red;
|
||||
@apply dark:border-dark-red;
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply !bg-opacity-50;
|
||||
}
|
||||
.ToastRoot[data-type='success'] {
|
||||
@apply border-light-green;
|
||||
@apply dark:border-dark-green;
|
||||
@apply bg-light-green;
|
||||
@apply dark:bg-dark-green;
|
||||
@apply !bg-opacity-50;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-light-40;
|
||||
@apply dark:bg-dark-40;
|
||||
@apply rounded-md;
|
||||
@apply border-2;
|
||||
@apply border-solid;
|
||||
@apply border-transparent;
|
||||
@apply bg-clip-padding;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-light-50;
|
||||
@apply dark:bg-dark-50;
|
||||
}
|
||||
.callout[data-type="abstract"],
|
||||
.callout[data-type="summary"],
|
||||
.callout[data-type="tldr"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="info"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="todo"]
|
||||
{
|
||||
@apply bg-light-blue;
|
||||
@apply dark:bg-dark-blue;
|
||||
@apply text-light-blue;
|
||||
@apply dark:text-dark-blue;
|
||||
}
|
||||
.callout[data-type="important"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="tip"],
|
||||
.callout[data-type="hint"]
|
||||
{
|
||||
@apply bg-light-cyan;
|
||||
@apply dark:bg-dark-cyan;
|
||||
@apply text-light-cyan;
|
||||
@apply dark:text-dark-cyan;
|
||||
}
|
||||
.callout[data-type="success"],
|
||||
.callout[data-type="check"],
|
||||
.callout[data-type="done"]
|
||||
{
|
||||
@apply bg-light-green;
|
||||
@apply dark:bg-dark-green;
|
||||
@apply text-light-green;
|
||||
@apply dark:text-dark-green;
|
||||
}
|
||||
.callout[data-type="question"],
|
||||
.callout[data-type="help"],
|
||||
.callout[data-type="faq"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="warning"],
|
||||
.callout[data-type="caution"],
|
||||
.callout[data-type="attention"]
|
||||
{
|
||||
@apply bg-light-orange;
|
||||
@apply dark:bg-dark-orange;
|
||||
@apply text-light-orange;
|
||||
@apply dark:text-dark-orange;
|
||||
}
|
||||
.callout[data-type="failure"],
|
||||
.callout[data-type="fail"],
|
||||
.callout[data-type="missing"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="danger"],
|
||||
.callout[data-type="error"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="bug"]
|
||||
{
|
||||
@apply bg-light-red;
|
||||
@apply dark:bg-dark-red;
|
||||
@apply text-light-red;
|
||||
@apply dark:text-dark-red;
|
||||
}
|
||||
.callout[data-type="example"]
|
||||
{
|
||||
@apply bg-light-purple;
|
||||
@apply dark:bg-dark-purple;
|
||||
@apply text-light-purple;
|
||||
@apply dark:text-dark-purple;
|
||||
}
|
||||
.variant-cap
|
||||
{
|
||||
font-variant: small-caps;
|
||||
}
|
||||
.cm-editor
|
||||
{
|
||||
@apply bg-transparent;
|
||||
@apply flex-1 h-full;
|
||||
@apply font-sans;
|
||||
|
||||
@apply text-light-100 dark:text-dark-100;
|
||||
}
|
||||
.cm-editor .cm-content
|
||||
{
|
||||
@apply caret-light-100 dark:caret-dark-100;
|
||||
}
|
||||
.cm-line
|
||||
{
|
||||
@apply text-base;
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
@apply max-w-[400px];
|
||||
@apply !bg-light-20;
|
||||
@apply dark:!bg-dark-20;
|
||||
@apply !border-light-40;
|
||||
@apply dark:!border-dark-40;
|
||||
}
|
||||
|
||||
/* .cm-tooltip-autocomplete > ul {
|
||||
@apply p-1;
|
||||
} */
|
||||
|
||||
.cm-tooltip-autocomplete > ul > li {
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply !py-1;
|
||||
@apply hover:bg-light-30;
|
||||
@apply dark:hover:bg-dark-30;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||
@apply !bg-light-35;
|
||||
@apply dark:!bg-dark-35;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply !hidden;
|
||||
}
|
||||
|
||||
.cm-completionLabel {
|
||||
@apply px-4;
|
||||
@apply font-sans;
|
||||
@apply font-normal;
|
||||
@apply text-base;
|
||||
@apply text-light-100;
|
||||
@apply dark:text-dark-100;
|
||||
}
|
||||
|
||||
.cm-completionMatchedText {
|
||||
@apply font-bold;
|
||||
@apply !no-underline;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
@apply font-sans;
|
||||
@apply font-normal;
|
||||
@apply text-sm;
|
||||
@apply text-light-60;
|
||||
@apply dark:text-dark-60;
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
</style>
|
||||
20
app/components/MarkdownRenderer.vue
Normal file
20
app/components/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import render, { type MDProperties } from '#shared/markdown.util'
|
||||
const { content, filter, properties } = defineProps<{
|
||||
content?: string,
|
||||
filter?: string,
|
||||
properties?: MDProperties
|
||||
}>();
|
||||
|
||||
const container = useTemplateRef('container');
|
||||
|
||||
content && onMounted(() => {
|
||||
queueMicrotask(() => {
|
||||
container.value && content && container.value.replaceChildren(render(content, filter, properties));
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container"></div>
|
||||
</template>
|
||||
16
app/components/ThemeSwitch.client.vue
Normal file
16
app/components/ThemeSwitch.client.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const isDark = computed({
|
||||
get() {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set() {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch v-model="isDark" onIcon="radix-icons:moon" offIcon="radix-icons:sun" />
|
||||
</template>
|
||||
30
app/components/base/Avatar.vue
Normal file
30
app/components/base/Avatar.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<AvatarRoot class="inline-flex select-none items-center justify-center overflow-hidden align-middle" :class="SIZES[size]">
|
||||
<AvatarImage class="h-full w-full object-cover" :src="src" asChild @loading-status-change="(status) => loading = status === 'loading'">
|
||||
<img :src="src" />
|
||||
</AvatarImage>
|
||||
<AvatarFallback :delay-ms="0" class="text-light-100 dark:text-dark-100 leading-1 flex h-full w-full p-4 items-center justify-center bg-light-25 dark:bg-dark-25 font-medium">
|
||||
<Loading v-if="loading" />
|
||||
<Icon v-else-if="!!icon" :icon="icon" class="w-full h-full" />
|
||||
<span v-else-if="!!text">{{ text }}</span>
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
const { src, icon, text, size = 'medium' } = defineProps<{
|
||||
src: string
|
||||
icon?: string
|
||||
text?: string
|
||||
size?: keyof typeof SIZES
|
||||
}>();
|
||||
const loading = ref(true);
|
||||
</script>
|
||||
<script lang="ts">
|
||||
const SIZES = {
|
||||
'small': 'h-6',
|
||||
'medium': 'h-10',
|
||||
'large': 'h-16',
|
||||
};
|
||||
</script>
|
||||
18
app/components/base/Button.vue
Normal file
18
app/components/base/Button.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<button :disabled="disabled" class="text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
|
||||
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50"
|
||||
:class="{'p-1': loading || icon, 'h-[35px] px-[15px]': !loading && !icon}" @click="!loading && emit('click')">
|
||||
<Loading v-if="loading" />
|
||||
<slot v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { icon = false, loading = false, disabled = false } = defineProps<{
|
||||
icon?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const emit = defineEmits(['click']);
|
||||
</script>
|
||||
47
app/components/base/Collapsible.vue
Normal file
47
app/components/base/Collapsible.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<CollapsibleRoot v-model:open="model" :disabled="disabled" :defaultOpen="defaultOpen">
|
||||
<slot name="alwaysVisible"></slot>
|
||||
<div class="flex flex-row justify-center items-center">
|
||||
<span>{{ label }}<slot name="label"></slot></span>
|
||||
<CollapsibleTrigger class="ms-4" asChild>
|
||||
<Button icon :disabled="disabled">
|
||||
<Icon v-if="model" icon="radix-icons:cross-2" class="h-4 w-4" />
|
||||
<Icon v-else icon="radix-icons:row-spacing" class="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent class="overflow-hidden data-[state=closed]:animate-[collapseClose_0.2s_ease-in-out] data-[state=open]:animate-[collapseOpen_0.2s_ease-in-out]">
|
||||
<slot></slot>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
const { label, disabled = false, defaultOpen = false } = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
defaultOpen?: boolean
|
||||
}>();
|
||||
const model = defineModel<boolean>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes collapseOpen {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collapseClose {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
app/components/base/Combobox.vue
Normal file
45
app/components/base/Combobox.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
|
||||
<span class="pb-1 md:p-0">{{ label }}</span>
|
||||
<ComboboxRoot v-model:model-value="model" v-model:open="open" :multiple="multiple">
|
||||
<ComboboxAnchor :disabled="disabled" class="mx-4 inline-flex min-w-[150px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
|
||||
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
|
||||
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
hover:border-light-50 dark:hover:border-dark-50">
|
||||
<ComboboxTrigger class="flex flex-1 justify-between !cursor-pointer">
|
||||
<span v-if="!multiple">{{ model !== undefined ? options.find(e => e[1] === model)![0] : "" }}</span>
|
||||
<span class="flex gap-2" v-else><span v-if="model !== undefined">{{ options.find(e => e[1] === (model as T[])[0]) !== undefined ? options.find(e => e[1] === (model as T[])[0])![0] : undefined }}</span><span v-if="model !== undefined && (model as T[]).length > 1">{{((model as T[]).length > 1 ? `+${(model as T[]).length - 1}` : "") }}</span></span>
|
||||
<Icon icon="radix-icons:caret-down" class="h-4 w-4" />
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal :disabled="disabled">
|
||||
<ComboboxContent :position="position" align="start" class="min-w-[150px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50">
|
||||
<ComboboxViewport>
|
||||
<ComboboxItem v-for="[label, value] of options" :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative Combobox-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
|
||||
<span class="">{{ label }}</span>
|
||||
<ComboboxItemIndicator class="absolute left-1 w-4 inline-flex items-center justify-center">
|
||||
<Icon icon="radix-icons:check" />
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string | number | boolean | Record<string, any>">
|
||||
import { ComboboxInput, ComboboxTrigger, ComboboxViewport, ComboboxContent, ComboboxPortal, ComboboxRoot } from 'radix-vue'
|
||||
import { Icon } from '@iconify/vue';
|
||||
const { disabled = false, position = 'popper', multiple = false } = defineProps<{
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
position?: 'inline' | 'popper'
|
||||
label?: string
|
||||
multiple?: boolean
|
||||
options: Array<[string, T]>
|
||||
}>();
|
||||
const open = ref(false);
|
||||
const model = defineModel<T | T[]>();
|
||||
</script>
|
||||
39
app/components/base/Dialog.vue
Normal file
39
app/components/base/Dialog.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<DialogRoot v-if="!priority" v-model="model">
|
||||
<DialogTrigger asChild><Button v-if="!!label">{{ label }}</Button><slot name="trigger" /></DialogTrigger>
|
||||
<DialogPortal v-if="!disabled">
|
||||
<DialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<DialogContent class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<DialogTitle v-if="!!title" class="text-3xl font-light relative -top-2">{{ title }}</DialogTitle>
|
||||
<DialogDescription v-if="!!description" class="text-base pb-2">{{ description }}</DialogDescription>
|
||||
<slot />
|
||||
<DialogClose v-if="iconClose" class="text-light-100 dark:text-dark-100 absolute top-2 right-2 inline-flex h-6 w-6 appearance-none items-center justify-center outline-none text-xl" aria-label="Close">
|
||||
<span aria-hidden>×</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
<AlertDialogRoot v-else v-model="model">
|
||||
<AlertDialogTrigger asChild><Button v-if="!!label">{{ label }}</Button><slot name="trigger" /></AlertDialogTrigger>
|
||||
<AlertDialogPortal v-if="!disabled">
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle v-if="!!title" class="text-3xl font-light relative -top-2">{{ title }}</AlertDialogTitle>
|
||||
<AlertDialogDescription v-if="!!description" class="text-base pb-2">{{ description }}</AlertDialogDescription>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { label, title, description, priority = false, disabled = false, iconClose = true } = defineProps<{
|
||||
label?: string
|
||||
title?: string
|
||||
description?: string
|
||||
priority?: boolean
|
||||
disabled?: boolean
|
||||
iconClose?: boolean
|
||||
}>();
|
||||
const model = defineModel();
|
||||
</script>
|
||||
80
app/components/base/DraggableTree.vue
Normal file
80
app/components/base/DraggableTree.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 overflow-auto max-h-full">
|
||||
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="group flex items-center outline-none relative cursor-pointer max-w-full" @select.prevent @toggle.prevent>
|
||||
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
|
||||
<slot :handleToggle="handleToggle"
|
||||
:handleSelect="handleSelect"
|
||||
:isExpanded="isExpanded"
|
||||
:isSelected="isSelected"
|
||||
:isDragging="isDragging"
|
||||
:isDraggedOver="isDraggedOver"
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
<template #hint="{ instruction }">
|
||||
<div v-if="instruction">
|
||||
<slot name="hint" :instruction="instruction" />
|
||||
</div>
|
||||
</template>
|
||||
</DraggableTreeItem>
|
||||
</TreeRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { useForwardPropsEmits, type FlattenedItem, type TreeRootEmits, type TreeRootProps } from 'radix-vue';
|
||||
import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
|
||||
const props = defineProps<TreeRootProps<T>>();
|
||||
const emits = defineEmits<TreeRootEmits<T> & {
|
||||
'updateTree': [instruction: Instruction, itemId: string, targetId: string];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
default: (props: {
|
||||
handleToggle: () => void,
|
||||
handleSelect: () => void,
|
||||
isExpanded: boolean,
|
||||
isSelected: boolean,
|
||||
isDragging: boolean,
|
||||
isDraggedOver: boolean,
|
||||
item: FlattenedItem<T>,
|
||||
}) => any,
|
||||
hint: (props: {
|
||||
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||
}) => any,
|
||||
}>();
|
||||
|
||||
const forward = useForwardPropsEmits(props, emits);
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const dndFunction = combine(
|
||||
monitorForElements({
|
||||
onDrop(args) {
|
||||
const { location, source } = args;
|
||||
|
||||
if (!location.current.dropTargets.length)
|
||||
return;
|
||||
|
||||
const itemId = source.data.id as string;
|
||||
const target = location.current.dropTargets[0];
|
||||
const targetId = target.data.id as string;
|
||||
|
||||
const instruction: Instruction | null = extractInstruction(
|
||||
target.data,
|
||||
);
|
||||
|
||||
if (instruction !== null)
|
||||
{
|
||||
emits('updateTree', instruction, itemId, targetId);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
dndFunction();
|
||||
})
|
||||
})
|
||||
</script>
|
||||
140
app/components/base/DraggableTreeItem.vue
Normal file
140
app/components/base/DraggableTreeItem.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
|
||||
<slot
|
||||
:is-expanded="isExpanded"
|
||||
:is-selected="isSelected"
|
||||
:is-indeterminate="isIndeterminate"
|
||||
:handle-select="handleSelect"
|
||||
:handle-toggle="handleToggle"
|
||||
:isDragging="isDragging"
|
||||
:isDraggedOver="isDraggedOver"
|
||||
/>
|
||||
<div v-if="instruction">
|
||||
<slot name="hint" :instruction="instruction" />
|
||||
</div>
|
||||
</TreeItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
|
||||
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||
|
||||
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
|
||||
const emits = defineEmits<TreeItemEmits<T>>();
|
||||
|
||||
defineSlots<{
|
||||
default: (props: {
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
isIndeterminate: boolean | undefined
|
||||
isDragging: boolean
|
||||
isDraggedOver: boolean
|
||||
handleToggle: () => void
|
||||
handleSelect: () => void
|
||||
}) => any,
|
||||
hint: (props: {
|
||||
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||
}) => any,
|
||||
}>()
|
||||
|
||||
const forward = useForwardPropsEmits(props, emits);
|
||||
|
||||
const element = templateRef('el');
|
||||
const isDragging = ref(false);
|
||||
const isDraggedOver = ref(false);
|
||||
const isInitialExpanded = ref(false);
|
||||
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
|
||||
|
||||
const mode = computed(() => {
|
||||
if (props.hasChildren)
|
||||
return 'expanded'
|
||||
if (props.index + 1 === props.parentItem?.children?.length)
|
||||
return 'last-in-group'
|
||||
return 'standard'
|
||||
});
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const currentElement = unrefElement(element) as HTMLElement;
|
||||
|
||||
if (!currentElement)
|
||||
return
|
||||
|
||||
const item = { ...props.value, level: props.level, id: props._id }
|
||||
|
||||
const expandItem = () => {
|
||||
if (!element.value?.isExpanded) {
|
||||
element.value?.handleToggle()
|
||||
}
|
||||
}
|
||||
|
||||
const closeItem = () => {
|
||||
if (element.value?.isExpanded) {
|
||||
element.value?.handleToggle()
|
||||
}
|
||||
}
|
||||
|
||||
const dndFunction = combine(
|
||||
draggable({
|
||||
element: currentElement,
|
||||
getInitialData: () => item,
|
||||
onDragStart: () => {
|
||||
isDragging.value = true
|
||||
isInitialExpanded.value = element.value?.isExpanded ?? false
|
||||
closeItem()
|
||||
},
|
||||
onDrop: () => {
|
||||
isDragging.value = false
|
||||
if (isInitialExpanded.value)
|
||||
expandItem()
|
||||
},
|
||||
}),
|
||||
|
||||
dropTargetForElements({
|
||||
element: currentElement,
|
||||
getData: ({ input, element }) => {
|
||||
const data = { id: item.id }
|
||||
|
||||
return attachInstruction(data, {
|
||||
input,
|
||||
element,
|
||||
indentPerLevel: 16,
|
||||
currentLevel: props.level,
|
||||
mode: mode.value,
|
||||
block: [],
|
||||
})
|
||||
},
|
||||
canDrop: ({ source }) => {
|
||||
return source.data.id !== item.id
|
||||
},
|
||||
onDrag: ({ self }) => {
|
||||
instruction.value = extractInstruction(self.data) as typeof instruction.value
|
||||
},
|
||||
onDragEnter: ({ source }) => {
|
||||
if (source.data.id !== item.id) {
|
||||
isDraggedOver.value = true
|
||||
}
|
||||
},
|
||||
onDragLeave: () => {
|
||||
isDraggedOver.value = false
|
||||
instruction.value = null
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
isDraggedOver.value = false
|
||||
instruction.value = null
|
||||
},
|
||||
getIsSticky: () => true,
|
||||
}),
|
||||
|
||||
monitorForElements({
|
||||
canMonitor: ({ source }) => {
|
||||
return source.data.id !== item.id
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Cleanup dnd function
|
||||
onCleanup(() => dndFunction())
|
||||
})
|
||||
</script>
|
||||
73
app/components/base/DropdownContentRender.vue
Normal file
73
app/components/base/DropdownContentRender.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<template v-for="(item, idx) of options">
|
||||
<template v-if="item.type === 'item'">
|
||||
<DropdownMenuItem :disabled="item.disabled" :textValue="item.label" @select="item.select" :class="{'!pe-1': item.kbd}" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="absolute left-1.5" />
|
||||
<div class="flex flex-1 justify-between">
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-else-if="item.type === 'checkbox'">
|
||||
<DropdownMenuCheckboxItem :disabled="item.disabled" :textValue="item.label" v-model:checked="item.checked" @update:checked="item.select" class="cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative pe-4 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||
<span class="w-6 flex items-center justify-center">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Icon icon="radix-icons:check" />
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<div class="flex flex-1 justify-between">
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="item.kbd" class="mx-2 text-xs font-mono text-light-70 dark:text-dark-70 relative top-0.5"> {{ item.kbd }} </span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
|
||||
<!-- TODO -->
|
||||
<template v-if="item.type === 'radio'">
|
||||
<DropdownMenuLabel>{{ item.label }}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup @update:model-value="item.change">
|
||||
<DropdownMenuRadioItem v-for="option in item.items" :disabled="(option as any)?.disabled ?? false" :value="(option as any)?.value ?? option">
|
||||
<DropdownMenuItemIndicator>
|
||||
<Icon icon="radix-icons:dot-filled" />
|
||||
</DropdownMenuItemIndicator>
|
||||
<span>{{ (option as any)?.label || option }}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
|
||||
</template>
|
||||
|
||||
<template v-if="item.type === 'submenu'">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger class="group cursor-pointer text-base text-light-100 dark:text-dark-100 leading-none flex items-center py-1.5 relative ps-7 select-none outline-none data-[disabled]:text-light-60 dark:data-[disabled]:text-dark-60 data-[disabled]:pointer-events-none data-[highlighted]:bg-light-35 dark:data-[highlighted]:bg-dark-35">
|
||||
<Icon v-if="item.icon" :icon="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
<Icon icon="radix-icons:chevron-right" class="absolute right-1" />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
|
||||
<DropdownContentRender :options="item.items" />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
|
||||
<template v-if="item.type === 'group'">
|
||||
<DropdownMenuLabel class="text-light-70 dark:text-dark-70 text-sm text-center pt-1">{{ item.label }}</DropdownMenuLabel>
|
||||
<DropdownContentRender :options="item.items" />
|
||||
|
||||
<DropdownMenuSeparator v-if="idx !== options.length - 1" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownOption } from './DropdownMenu.vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const { options } = defineProps<{
|
||||
options: DropdownOption[]
|
||||
}>();
|
||||
</script>
|
||||
58
app/components/base/DropdownMenu.vue
Normal file
58
app/components/base/DropdownMenu.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger :disabled="disabled"><slot /></DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent :align="align" :side="side" class="z-50 outline-none bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade border border-light-35 dark:border-dark-35">
|
||||
<DropdownContentRender :options="options" />
|
||||
|
||||
<DropdownMenuArrow class="fill-light-35 dark:fill-dark-35" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface DropdownItem {
|
||||
type: 'item';
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
select?: () => void;
|
||||
icon?: string;
|
||||
kbd?: string;
|
||||
}
|
||||
export interface DropdownCheckbox {
|
||||
type: 'checkbox';
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
checked?: boolean | Ref<boolean>
|
||||
select?: (state: boolean) => void;
|
||||
kbd?: string;
|
||||
}
|
||||
export interface DropdownRadioGroup {
|
||||
type: 'radio';
|
||||
label: string;
|
||||
value?: string | Ref<string>
|
||||
items: (string | {label: string, value?: string, disabled?: boolean})[];
|
||||
change?: (value: string) => void;
|
||||
}
|
||||
export interface DropdownSubmenu {
|
||||
type: 'submenu';
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
items: DropdownOption[];
|
||||
icon?: string;
|
||||
}
|
||||
export interface DropdownGroup {
|
||||
type: 'group';
|
||||
label?: string;
|
||||
items: DropdownOption[];
|
||||
}
|
||||
export type DropdownOption = DropdownItem | DropdownCheckbox | DropdownRadioGroup | DropdownSubmenu | DropdownGroup;
|
||||
const { options, disabled = false, side, align } = defineProps<{
|
||||
options: DropdownOption[]
|
||||
disabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>();
|
||||
</script>
|
||||
36
app/components/base/HoverCard.vue
Normal file
36
app/components/base/HoverCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<HoverCardRoot :open-delay="delay" @update:open="(...args) => emits('open', ...args)">
|
||||
<HoverCardTrigger class="inline-block cursor-help outline-none">
|
||||
<slot></slot>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal v-if="!disabled">
|
||||
<HoverCardContent :class="$attrs.class" :side="side" :align="align" avoidCollisions :collisionPadding="20" class="max-h-[var(--radix-hover-card-content-available-height)] data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 p-5 data-[state=open]:transition-all text-light-100 dark:text-dark-100" >
|
||||
<slot name="content"></slot>
|
||||
<HoverCardArrow class="fill-light-35 dark:fill-dark-35" />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { delay = 500, disabled = false, side = 'bottom', align = 'center', triggerKey } = defineProps<{
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
triggerKey?: string
|
||||
}>();
|
||||
|
||||
const emits = defineEmits(['open']);
|
||||
const canOpen = ref(true);
|
||||
|
||||
if(triggerKey)
|
||||
{
|
||||
const magicKeys = useMagicKeys();
|
||||
const keys = magicKeys[triggerKey];
|
||||
|
||||
watch(keys, (v) => {
|
||||
canOpen.value = v;
|
||||
}, { immediate: true, });
|
||||
}
|
||||
</script>
|
||||
3
app/components/base/Kbd.vue
Normal file
3
app/components/base/Kbd.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<span class="rounded bg-light-35 dark:bg-dark-35 font-mono text-sm px-1 py-0 select-none" style="box-shadow: black 0 2px 0 1px;"><slot /></span>
|
||||
</template>
|
||||
9
app/components/base/Loading.vue
Normal file
9
app/components/base/Loading.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<span :class="{'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}" class="after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin"></span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { size = 'normal' } = defineProps<{
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
}>();
|
||||
</script>
|
||||
23
app/components/base/NumberPicker.vue
Normal file
23
app/components/base/NumberPicker.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<Label class="my-2 flex">{{ label }}
|
||||
<NumberFieldRoot :min="min" :max="max" v-model="model" :disabled="disabled" :step="step" class="ms-4 flex justify-center border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
|
||||
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 hover:border-light-50 dark:hover:border-dark-50 has-[:focus]:shadow-raw transition-[box-shadow] has-[:focus]:shadow-light-40 dark:has-[:focus]:shadow-dark-40">
|
||||
<NumberFieldDecrement class="data-[disabled]:opacity-50 px-1"><Icon icon="radix-icons:minus" :inline="true" class="w-6 text-light-100 dark:text-dark-100 opacity-100" /></NumberFieldDecrement>
|
||||
<NumberFieldInput class="text-sm tabular-nums w-20 appearance-none bg-transparent px-2 py-1 outline-none caret-light-50 dark:caret-dark-50" />
|
||||
<NumberFieldIncrement class="data-[disabled]:opacity-50 px-1"><Icon icon="radix-icons:plus" :inline="true" class="w-6 text-light-100 dark:text-dark-100 opacity-100" /></NumberFieldIncrement>
|
||||
</NumberFieldRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const { min = 0, max = 100, disabled = false, step = 1, label } = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
disabled?: boolean
|
||||
step?: number
|
||||
label?: string
|
||||
}>();
|
||||
const model = defineModel<number>();
|
||||
</script>
|
||||
20
app/components/base/PinPicker.vue
Normal file
20
app/components/base/PinPicker.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<Label class="my-2">{{ label }}
|
||||
<PinInputRoot :disabled="disabled" :default-value="model?.split('')" @update:model-value="(v) => model = v.join('')" @complete="() => emit('complete')" class="flex gap-2 items-center mt-1">
|
||||
<PinInputInput :type="hidden ? 'password' : undefined" v-for="(id, index) in amount" :key="id" :index="index" class="border border-light-35 dark:border-dark-35 w-10 h-10 outline-none
|
||||
bg-light-20 dark:bg-dark-20 text-center text-light-100 dark:text-dark-100 placeholder:text-light-60 dark:placeholder:text-dark-60 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20
|
||||
data-[disabled]:text-light-70 dark:data-[disabled]:text-dark-70 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 caret-light-50 dark:caret-dark-50" />
|
||||
</PinInputRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { hidden = false, amount, label, disabled = false } = defineProps<{
|
||||
hidden?: boolean
|
||||
label?: string
|
||||
amount: number
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
const emit = defineEmits(['complete']);
|
||||
</script>
|
||||
13
app/components/base/Progress.vue
Normal file
13
app/components/base/Progress.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ProgressRoot :max="max" v-model="model" class="my-2 relative overflow-hidden bg-light-25 dark:bg-dark-25 w-48 h-3 data-[shape=thin]:h-1 data-[shape=large]:h-6" :data-shape="shape" style="transform: translateZ(0)" >
|
||||
<ProgressIndicator class="bg-light-50 dark:bg-dark-50 h-full transition-[width] duration-[660ms] ease-[cubic-bezier(0.65, 0, 0.35, 1)]" :style="`width: ${((model ?? 0) / max) * 100}%`" />
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { max = 100, shape = 'normal' } = defineProps<{
|
||||
max?: number
|
||||
shape?: 'thin' | 'normal' | 'large'
|
||||
}>();
|
||||
const model = defineModel<number>();
|
||||
</script>
|
||||
30
app/components/base/RadioInput.vue
Normal file
30
app/components/base/RadioInput.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<RadioGroupRoot :disabled="disabled" v-model="model" class="flex flex-col gap-2 p-2">
|
||||
<Label v-for="option in options" class="flex items-center gap-2">
|
||||
<RadioGroupItem :disabled="(option as RadioOption).disabled ?? false"
|
||||
:value="(option as RadioOption).value ?? option"
|
||||
class="border border-light-60 dark:border-dark-35 bg-light-20 dark:bg-dark-25 w-5 h-5 outline-none cursor-default hover:border-light-70 dark:hover:border-dark-40
|
||||
focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 data-[disabled]:bg-light-10 dark:data-[disabled]:bg-dark-10 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20">
|
||||
<RadioGroupIndicator>
|
||||
<Icon icon="radix-icons:check" class="relative w-5 h-5 -top-px -left-px" />
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
{{ (option as RadioOption).label ?? option }}
|
||||
</Label>
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
export interface RadioOption {
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const { disabled = false, options } = defineProps<{
|
||||
disabled?: boolean
|
||||
options: string[] | RadioOption[]
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
</script>
|
||||
42
app/components/base/Select.vue
Normal file
42
app/components/base/Select.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
|
||||
<span class="pb-1 md:p-0">{{ label }}</span>
|
||||
<SelectRoot v-model="model" :default-value="defaultValue">
|
||||
<SelectTrigger :disabled="disabled" class="mx-4 inline-flex min-w-[160px] items-center justify-between px-3 text-sm font-semibold leading-none h-8 gap-1
|
||||
bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none data-[placeholder]:font-normal
|
||||
data-[placeholder]:text-light-50 dark:data-[placeholder]:text-dark-50 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
hover:border-light-50 dark:hover:border-dark-50">
|
||||
<SelectValue :placeholder="placeholder" />
|
||||
<Icon icon="radix-icons:caret-down" class="h-4 w-4" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectPortal :disabled="disabled">
|
||||
<SelectContent :position="position"
|
||||
class="min-w-[160px] bg-light-20 dark:bg-dark-20 will-change-[opacity,transform] z-50">
|
||||
<SelectScrollUpButton>
|
||||
<Icon icon="radix-icons:chevron-up" />
|
||||
</SelectScrollUpButton>
|
||||
<SelectViewport>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton>
|
||||
<Icon icon="radix-icons:chevron-down" />
|
||||
</SelectScrollDownButton>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectContent, SelectPortal, SelectRoot, SelectScrollDownButton, SelectScrollUpButton, SelectTrigger, SelectValue, SelectViewport } from 'radix-vue'
|
||||
import { Icon } from '@iconify/vue';
|
||||
const { disabled = false, position = 'popper' } = defineProps<{
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
position?: 'item-aligned' | 'popper'
|
||||
label?: string
|
||||
defaultValue?: string
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
</script>
|
||||
14
app/components/base/SelectGroup.vue
Normal file
14
app/components/base/SelectGroup.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<SelectGroup :disabled="disabled" class="">
|
||||
<SelectLabel class="">{{ label }}</SelectLabel>
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectGroup } from 'radix-vue';
|
||||
const { label, disabled = false } = defineProps<{
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}>();
|
||||
</script>
|
||||
18
app/components/base/SelectItem.vue
Normal file
18
app/components/base/SelectItem.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<SelectItem :value="value" :disabled="disabled" class="text-base py-2 leading-none text-light-60 dark:text-dark-60 flex items-center px-6 relative select-none data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50 data-[disabled]:pointer-events-none data-[highlighted]:outline-none data-[highlighted]:bg-light-30 dark:data-[highlighted]:bg-dark-30 data-[highlighted]:text-light-100 dark:data-[highlighted]:text-dark-100">
|
||||
<SelectItemText class="">{{ label }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute left-1 w-4 inline-flex items-center justify-center">
|
||||
<Icon icon="radix-icons:check" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText } from 'radix-vue'
|
||||
const { disabled = false, value } = defineProps<{
|
||||
disabled?: boolean
|
||||
value: NonNullable<string>
|
||||
label: string
|
||||
}>();
|
||||
</script>
|
||||
7
app/components/base/SelectSeparator.vue
Normal file
7
app/components/base/SelectSeparator.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<SelectSeparator class="" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectSeparator } from 'radix-vue';
|
||||
</script>
|
||||
25
app/components/base/SliderInput.vue
Normal file
25
app/components/base/SliderInput.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Label class="flex justify-center items-center my-2">{{ label }}
|
||||
<SliderRoot class="mx-4 relative flex items-center select-none touch-none w-[160px] h-5"
|
||||
:default-value="model ? [model] : undefined" :v-model="[model]" :disabled="disabled"
|
||||
@update:model-value="(value) => model = value ? value[0] : min" :min="min" :max="max" :step="step">
|
||||
<SliderTrack class="bg-light-30 dark:bg-dark-30 relative h-1 w-full data-[disabled]:bg-light-10 dark:data-[disabled]:bg-dark-10">
|
||||
<SliderRange class="absolute bg-light-40 dark:bg-dark-40 h-full data-[disabled]:bg-light-30 dark:data-[disabled]:bg-dark-30" />
|
||||
</SliderTrack>
|
||||
<SliderThumb
|
||||
class="block w-5 h-5 bg-light-50 dark:bg-dark-50 outline-none focus:shadow-raw transition-[box-shadow] focus:shadow-light-60 dark:focus:shadow-dark-60 border border-light-50 dark:border-dark-50
|
||||
hover:border-light-60 dark:hover:border-dark-60 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20" />
|
||||
</SliderRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { min = 0, max = 100, step = 1, label, disabled = false } = defineProps<{
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>();
|
||||
const model = defineModel<number>()
|
||||
</script>
|
||||
27
app/components/base/Switch.vue
Normal file
27
app/components/base/Switch.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<Label class="flex justify-center items-center my-2">
|
||||
<span class="md:text-base text-sm">{{ label }}</span>
|
||||
<SwitchRoot v-model:checked="model" :disabled="disabled" :default-checked="defaultValue"
|
||||
class="group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
|
||||
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative">
|
||||
<SwitchThumb
|
||||
class="block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 data-[state=checked]:translate-x-[26px]
|
||||
data-[disabled]:bg-light-30 dark:data-[disabled]:bg-dark-30 data-[disabled]:border-light-30 dark:data-[disabled]:border-dark-30" />
|
||||
<Icon v-if="onIcon && offIcon" :icon="onIcon" class="group-data-[state=checked]:opacity-100 group-data-[state=unchecked]:opacity-0 absolute top-1 left-1 transition-opacity" />
|
||||
<Icon v-if="onIcon && offIcon" :icon="offIcon" class="group-data-[state=checked]:opacity-0 group-data-[state=unchecked]:opacity-100 absolute top-1 right-1 transition-opacity" />
|
||||
</SwitchRoot>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
const { label, disabled, onIcon, offIcon } = defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
onIcon?: string
|
||||
offIcon?: string
|
||||
defaultValue?: boolean
|
||||
}>();
|
||||
const model = defineModel<boolean>();
|
||||
</script>
|
||||
21
app/components/base/TagsInput.vue
Normal file
21
app/components/base/TagsInput.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<TagsInputRoot v-model="model" addOnPaste class="flex gap-2 items-center border p-2 w-full flex-wrap border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10" >
|
||||
<TagsInputItem v-for="item in model" :key="item" :value="item" class="text-light-100 dark:text-dark-100 flex items-center justify-center gap-2 bg-light-20 dark:bg-dark-20 hover:bg-light-35 dark:hover:bg-dark-35 p-1 border border-light-35 dark:border-dark-35">
|
||||
<TagsInputItemText class="text-sm pl-1" />
|
||||
<TagsInputItemDelete asChild>
|
||||
<Icon icon="radix-icons:cross-2" class="w-4 h-4 cursor-pointer" />
|
||||
</TagsInputItemDelete>
|
||||
</TagsInputItem>
|
||||
|
||||
<TagsInputInput :placeholder="placeholder" class="text-sm focus:outline-none flex-1 rounded text-green9 bg-transparent placeholder:text-mauve9 px-1" />
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const { placeholder } = defineProps<{
|
||||
placeholder?: string
|
||||
}>();
|
||||
const model = defineModel<string[]>();
|
||||
</script>
|
||||
25
app/components/base/TextInput.vue
Normal file
25
app/components/base/TextInput.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Label class="my-2 flex flex-1 items-center justify-between flex-col md:flex-row">
|
||||
<span class="pb-1 md:p-0">{{ label }}</span>
|
||||
<input :placeholder="placeholder" :disabled="disabled"
|
||||
class="mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50
|
||||
bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
|
||||
border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20"
|
||||
:type="type" v-model="model" :data-disabled="disabled || undefined" v-bind="$attrs" @change="(e) => emits('change', e)" @input="(e) => emits('input', e)">
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { type = 'text', label, disabled = false, placeholder } = defineProps<{
|
||||
type?: 'text' | 'password' | 'email' | 'tel' | 'url'
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
change: [Event]
|
||||
input: [Event]
|
||||
}>();
|
||||
const model = defineModel<string>();
|
||||
</script>
|
||||
85
app/components/base/Tooltip.vue
Normal file
85
app/components/base/Tooltip.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<TooltipRoot :delay-duration="delay" :disabled="disabled">
|
||||
<TooltipTrigger asChild>
|
||||
<span tabindex="0"><slot></slot></span>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50" :class="$attrs.class" :side="side" :align="align" :align-offset="-16" :side-offset="['left', 'right'].includes(side ?? '') ? 8 : 0">
|
||||
{{ message }}
|
||||
<TooltipArrow class="fill-light-30 dark:fill-dark-30"></TooltipArrow>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { message, delay = 300, side } = defineProps<{
|
||||
message: string
|
||||
delay?: number
|
||||
disabled?: boolean
|
||||
side?: 'left' | 'right' | 'top' | 'bottom'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.TooltipContent {
|
||||
animation-duration: .3s;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.TooltipContent[data-side="top"] {
|
||||
animation-name: slideUp;
|
||||
}
|
||||
.TooltipContent[data-side="bottom"] {
|
||||
animation-name: slideDown;
|
||||
}
|
||||
.TooltipContent[data-side="left"] {
|
||||
animation-name: slideLeft;
|
||||
}
|
||||
.TooltipContent[data-side="right"] {
|
||||
animation-name: slideRight;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes slideRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
app/components/base/Tree.vue
Normal file
20
app/components/base/Tree.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<TreeRoot v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 text-sm" :items="model" :get-key="getKey" :defaultExpanded="flatten(model)">
|
||||
<TreeItem v-for="item in flattenItems" v-slot="{ isExpanded }" :key="item._id" :style="{ 'padding-left': `${item.level / 2 - 0.5}em` }" v-bind="item.bind" class="flex items-center ps-2 outline-none relative cursor-pointer">
|
||||
<slot :isExpanded="isExpanded" :item="item" />
|
||||
</TreeItem>
|
||||
</TreeRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
const { getKey } = defineProps<{
|
||||
getKey: (val: T) => string
|
||||
}>();
|
||||
|
||||
const model = defineModel<T[]>();
|
||||
|
||||
function flatten(arr: T[]): string[]
|
||||
{
|
||||
return arr.filter(e => e.open).flatMap(e => [getKey(e), ...flatten(e.children ?? [])]);
|
||||
}
|
||||
</script>
|
||||
22
app/composables/useDatabase.ts
Normal file
22
app/composables/useDatabase.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import * as schema from '../db/schema';
|
||||
|
||||
let instance: BunSQLiteDatabase<typeof schema> & {
|
||||
$client: Database;
|
||||
};
|
||||
export default function useDatabase()
|
||||
{
|
||||
if(!instance)
|
||||
{
|
||||
const database = useRuntimeConfig().database;
|
||||
const sqlite = new Database(database);
|
||||
instance = drizzle({ client: sqlite, schema, /* logger: true */ });
|
||||
|
||||
instance.run("PRAGMA journal_mode = WAL;");
|
||||
instance.run("PRAGMA foreign_keys = true;");
|
||||
instance.run("PRAGMA optimize=0x10002;");
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
58
app/composables/useMarkdown.ts
Normal file
58
app/composables/useMarkdown.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { unified, type Processor } from "unified";
|
||||
import type { Root } from 'hast';
|
||||
import RemarkParse from "remark-parse";
|
||||
|
||||
import RemarkRehype from 'remark-rehype';
|
||||
import RemarkOfm from 'remark-ofm';
|
||||
import RemarkGfm from 'remark-gfm';
|
||||
import RemarkBreaks from 'remark-breaks';
|
||||
import RemarkFrontmatter from 'remark-frontmatter';
|
||||
import StripMarkdown from 'strip-markdown';
|
||||
import RemarkStringify from 'remark-stringify';
|
||||
|
||||
interface Parser
|
||||
{
|
||||
parse: (md: string) => Promise<Root>;
|
||||
parseSync: (md: string) => Root;
|
||||
text: (md: string) => string;
|
||||
}
|
||||
export default function useMarkdown(): Parser
|
||||
{
|
||||
let processor: Processor, processorSync: Processor;
|
||||
|
||||
const parse = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype);
|
||||
}
|
||||
|
||||
const processed = processor.run(processor.parse(markdown)) as Promise<Root>;
|
||||
return processed;
|
||||
}
|
||||
|
||||
const parseSync = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter]);
|
||||
processor.use(RemarkRehype);
|
||||
}
|
||||
|
||||
const processed = processor.runSync(processor.parse(markdown)) as Root;
|
||||
return processed;
|
||||
}
|
||||
|
||||
const text = (markdown: string) => {
|
||||
if (!processor)
|
||||
{
|
||||
processor = unified().use([RemarkParse, RemarkGfm, RemarkOfm, RemarkBreaks, RemarkFrontmatter ]);
|
||||
processor.use(StripMarkdown, { remove: [ 'comment', 'tag', 'callout' ] });
|
||||
processor.use(RemarkStringify);
|
||||
}
|
||||
|
||||
const processed = processor.processSync(markdown);
|
||||
return String(processed);
|
||||
}
|
||||
|
||||
return { parse, parseSync, text };
|
||||
}
|
||||
194
app/composables/useShortcuts.ts
Normal file
194
app/composables/useShortcuts.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ComputedRef, WatchSource } from 'vue'
|
||||
import { logicAnd, logicNot } from '@vueuse/math'
|
||||
import { useEventListener, useDebounceFn, createSharedComposable, useActiveElement } from '@vueuse/core'
|
||||
|
||||
export interface ShortcutConfig {
|
||||
handler: Function
|
||||
usingInput?: string | boolean
|
||||
whenever?: WatchSource<boolean>[]
|
||||
prevent?: boolean
|
||||
}
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
[key: string]: ShortcutConfig | Function
|
||||
}
|
||||
|
||||
export interface ShortcutsOptions {
|
||||
chainDelay?: number
|
||||
}
|
||||
|
||||
interface Shortcut {
|
||||
handler: Function
|
||||
condition: ComputedRef<boolean>
|
||||
chained: boolean
|
||||
// KeyboardEvent attributes
|
||||
key: string
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
shiftKey: boolean
|
||||
altKey: boolean
|
||||
// code?: string
|
||||
// keyCode?: number
|
||||
prevent?: boolean
|
||||
}
|
||||
|
||||
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
|
||||
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
|
||||
|
||||
export const useShortcuts = (config: ShortcutsConfig, options: ShortcutsOptions = {}) => {
|
||||
const { macOS, usingInput } = _useShortcuts()
|
||||
|
||||
let shortcuts: Shortcut[] = []
|
||||
|
||||
const chainedInputs = ref<string[]>([])
|
||||
const clearChainedInput = () => {
|
||||
chainedInputs.value.splice(0, chainedInputs.value.length)
|
||||
}
|
||||
const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800)
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Input autocomplete triggers a keydown event
|
||||
if (!e.key) { return }
|
||||
|
||||
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
|
||||
|
||||
let chainedKey
|
||||
chainedInputs.value.push(e.key)
|
||||
// try matching a chained shortcut
|
||||
if (chainedInputs.value.length >= 2) {
|
||||
chainedKey = chainedInputs.value.slice(-2).join('-')
|
||||
|
||||
for (const shortcut of shortcuts.filter(s => s.chained)) {
|
||||
if (shortcut.key !== chainedKey) { continue }
|
||||
|
||||
if (shortcut.condition.value) {
|
||||
e.stopPropagation
|
||||
shortcut.prevent && e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
clearChainedInput()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// try matching a standard shortcut
|
||||
for (const shortcut of shortcuts.filter(s => !s.chained)) {
|
||||
if (e.key.toLowerCase() !== shortcut.key) { continue }
|
||||
if (e.metaKey !== shortcut.metaKey) { continue }
|
||||
if (e.ctrlKey !== shortcut.ctrlKey) { continue }
|
||||
// shift modifier is only checked in combination with alphabetical keys
|
||||
// (shift with non-alphabetical keys would change the key)
|
||||
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue }
|
||||
// alt modifier changes the combined key anyways
|
||||
// if (e.altKey !== shortcut.altKey) { continue }
|
||||
|
||||
if (shortcut.condition.value) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
clearChainedInput()
|
||||
return
|
||||
}
|
||||
|
||||
debouncedClearChainedInput()
|
||||
}
|
||||
|
||||
// Map config to full detailled shortcuts
|
||||
shortcuts = Object.entries(config).map(([key, shortcutConfig]) => {
|
||||
if (!shortcutConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse key and modifiers
|
||||
let shortcut: Partial<Shortcut>
|
||||
|
||||
if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) {
|
||||
console.trace(`[Shortcut] Invalid key: "${key}"`)
|
||||
}
|
||||
|
||||
if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) {
|
||||
console.trace(`[Shortcut] Invalid key: "${key}"`)
|
||||
}
|
||||
|
||||
const chained = key.includes('-') && key !== '-'
|
||||
if (chained) {
|
||||
shortcut = {
|
||||
key: key.toLowerCase(),
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false
|
||||
}
|
||||
} else {
|
||||
const keySplit = key.toLowerCase().split('_').map(k => k)
|
||||
shortcut = {
|
||||
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
|
||||
metaKey: keySplit.includes('meta'),
|
||||
ctrlKey: keySplit.includes('ctrl'),
|
||||
shiftKey: keySplit.includes('shift'),
|
||||
altKey: keySplit.includes('alt')
|
||||
}
|
||||
}
|
||||
shortcut.chained = chained
|
||||
|
||||
// Convert Meta to Ctrl for non-MacOS
|
||||
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
|
||||
shortcut.metaKey = false
|
||||
shortcut.ctrlKey = true
|
||||
}
|
||||
|
||||
// Retrieve handler function
|
||||
if (typeof shortcutConfig === 'function') {
|
||||
shortcut.handler = shortcutConfig
|
||||
} else if (typeof shortcutConfig === 'object') {
|
||||
shortcut = { ...shortcut, handler: shortcutConfig.handler, prevent: shortcutConfig.prevent }
|
||||
}
|
||||
|
||||
if (!shortcut.handler) {
|
||||
console.trace('[Shortcut] Invalid value')
|
||||
return null
|
||||
}
|
||||
|
||||
// Create shortcut computed
|
||||
const conditions: ComputedRef<boolean>[] = []
|
||||
if (!(shortcutConfig as ShortcutConfig).usingInput) {
|
||||
conditions.push(logicNot(usingInput))
|
||||
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
|
||||
conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput))
|
||||
}
|
||||
shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || []))
|
||||
|
||||
return shortcut as Shortcut
|
||||
}).filter(Boolean) as Shortcut[]
|
||||
|
||||
useEventListener('keydown', onKeyDown)
|
||||
}
|
||||
|
||||
export const _useShortcuts = () => {
|
||||
const macOS = computed(() => process.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
|
||||
|
||||
const metaSymbol = ref(' ')
|
||||
|
||||
const activeElement = useActiveElement()
|
||||
const usingInput = computed(() => {
|
||||
const usingInput = !!(activeElement.value?.tagName === 'INPUT' || activeElement.value?.tagName === 'TEXTAREA' || activeElement.value?.contentEditable === 'true')
|
||||
|
||||
if (usingInput) {
|
||||
return ((activeElement.value as any)?.name as string) || true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
tryOnMounted(() => {
|
||||
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
|
||||
})
|
||||
|
||||
return {
|
||||
macOS,
|
||||
metaSymbol,
|
||||
activeElement,
|
||||
usingInput
|
||||
}
|
||||
}
|
||||
38
app/composables/useUserSession.ts
Normal file
38
app/composables/useUserSession.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { UserSession, UserSessionComposable } from '~/types/auth'
|
||||
|
||||
const useSessionState = () => useState<UserSession>('nuxt-session', () => ({}))
|
||||
const useAuthReadyState = () => useState('nuxt-auth-ready', () => false)
|
||||
|
||||
/**
|
||||
* Composable to get back the user session and utils around it.
|
||||
*/
|
||||
export function useUserSession(): UserSessionComposable {
|
||||
const sessionState = useSessionState()
|
||||
const authReadyState = useAuthReadyState()
|
||||
return {
|
||||
ready: computed(() => authReadyState.value),
|
||||
loggedIn: computed(() => Boolean(sessionState.value.user)),
|
||||
user: computed(() => sessionState.value.user ?? null),
|
||||
session: sessionState,
|
||||
fetch,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch(): Promise<boolean> {
|
||||
const authReadyState = useAuthReadyState()
|
||||
const sessionState = useSessionState()
|
||||
const loggedIn = Boolean(sessionState.value.user)
|
||||
sessionState.value = await useRequestFetch()('/api/auth/session').catch(() => ({}))
|
||||
if (!authReadyState.value)
|
||||
{
|
||||
authReadyState.value = true
|
||||
}
|
||||
return loggedIn !== Boolean(sessionState.value.user);
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
await useRequestFetch()('/api/auth/session', { method: 'DELETE' })
|
||||
useSessionState().value = {}
|
||||
useRouter().go(0)
|
||||
}
|
||||
169
app/db/schema.ts
Normal file
169
app/db/schema.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const usersTable = table("users", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
username: text().notNull().unique(),
|
||||
email: text().notNull().unique(),
|
||||
hash: text().notNull().unique(),
|
||||
state: int().notNull().default(0),
|
||||
});
|
||||
export const usersDataTable = table("users_data", {
|
||||
id: int().primaryKey().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
signin: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
lastTimestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
export const userSessionsTable = table("user_sessions", {
|
||||
id: int().notNull(),
|
||||
user_id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.user_id] })]);
|
||||
export const userPermissionsTable = table("user_permissions", {
|
||||
id: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
permission: text().notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.permission] })]);
|
||||
|
||||
export const projectFilesTable = table("project_files", {
|
||||
id: text().primaryKey(),
|
||||
path: text().notNull().unique(),
|
||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
title: text().notNull(),
|
||||
type: text({ enum: ['file', 'folder', 'markdown', 'canvas', 'map'] }).notNull(),
|
||||
navigable: int({ mode: 'boolean' }).notNull().default(true),
|
||||
private: int({ mode: 'boolean' }).notNull().default(false),
|
||||
order: int().notNull(),
|
||||
timestamp: int({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
});
|
||||
export const projectContentTable = table("project_content", {
|
||||
id: text().primaryKey(),
|
||||
content: blob({ mode: 'buffer' }),
|
||||
});
|
||||
|
||||
export const emailValidationTable = table("email_validation", {
|
||||
id: text().primaryKey(),
|
||||
timestamp: int({ mode: 'timestamp' }).notNull(),
|
||||
})
|
||||
|
||||
export const characterTable = table("character", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
people: text().notNull(),
|
||||
level: int().notNull().default(1),
|
||||
variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"items": [],"exhaustion": 0,"sickness": [],"poisons": []}'),
|
||||
aspect: int(),
|
||||
public_notes: text(),
|
||||
private_notes: text(),
|
||||
|
||||
visibility: text({ enum: ['private', 'public'] }).notNull().default('private'),
|
||||
thumbnail: blob(),
|
||||
});
|
||||
export const characterTrainingTable = table("character_training", {
|
||||
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
stat: text({ enum: ["strength","dexterity","constitution","intelligence","curiosity","charisma","psyche"] }).notNull(),
|
||||
level: int().notNull(),
|
||||
choice: int().notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.character, table.stat, table.level] })]);
|
||||
export const characterLevelingTable = table("character_leveling", {
|
||||
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
level: int().notNull(),
|
||||
choice: int().notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.character, table.level] })]);
|
||||
export const characterAbilitiesTable = table("character_abilities", {
|
||||
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
ability: text({ enum: ["athletics","acrobatics","intimidation","sleightofhand","stealth","survival","investigation","history","religion","arcana","understanding","perception","performance","medecine","persuasion","animalhandling","deception"] }).notNull(),
|
||||
value: int().notNull().default(0),
|
||||
max: int().notNull().default(0),
|
||||
}, (table) => [primaryKey({ columns: [table.character, table.ability] })]);
|
||||
export const characterChoicesTable = table("character_choices", {
|
||||
character: int().notNull().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
id: text().notNull(),
|
||||
choice: int().notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.character, table.id, table.choice] })]);
|
||||
|
||||
export const campaignTable = table("campaign", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
link: text().notNull(),
|
||||
status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'),
|
||||
inventory: text({ mode: 'json' }).default('[]'),
|
||||
money: int().default(0),
|
||||
});
|
||||
export const campaignMembersTable = table("campaign_members", {
|
||||
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
user: int().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
rights: text({ enum: [ 'player', 'dm' ] }),
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.user] })]);
|
||||
export const campaignCharactersTable = table("campaign_characters", {
|
||||
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
character: int().references(() => characterTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.character] })]);
|
||||
export const campaignLogsTable = table("campaign_logs", {
|
||||
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
from: int().references(() => campaignCharactersTable.character, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
timestamp: int({ mode: 'timestamp_ms' }).notNull(),
|
||||
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
|
||||
details: text().notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.from, table.timestamp] })]);
|
||||
|
||||
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
||||
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
|
||||
session: many(userSessionsTable),
|
||||
permission: many(userPermissionsTable),
|
||||
files: many(projectFilesTable),
|
||||
characters: many(characterTable),
|
||||
}));
|
||||
export const usersDataRelation = relations(usersDataTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [usersDataTable.id], references: [usersTable.id], }),
|
||||
}));
|
||||
export const userSessionsRelation = relations(userSessionsTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [userSessionsTable.user_id], references: [usersTable.id], }),
|
||||
}));
|
||||
export const userPermissionsRelation = relations(userPermissionsTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [userPermissionsTable.id], references: [usersTable.id], }),
|
||||
}));
|
||||
export const projectFilesRelation = relations(projectFilesTable, ({ one }) => ({
|
||||
users: one(usersTable, { fields: [projectFilesTable.owner], references: [usersTable.id], }),
|
||||
}));
|
||||
|
||||
export const characterRelation = relations(characterTable, ({ one, many }) => ({
|
||||
user: one(usersTable, { fields: [characterTable.owner], references: [usersTable.id], }),
|
||||
training: many(characterTrainingTable),
|
||||
levels: many(characterLevelingTable),
|
||||
abilities: many(characterAbilitiesTable),
|
||||
choices: many(characterChoicesTable),
|
||||
campaign: one(campaignCharactersTable, { fields: [characterTable.id], references: [campaignCharactersTable.character], }),
|
||||
}));
|
||||
export const characterTrainingRelation = relations(characterTrainingTable, ({ one }) => ({
|
||||
character: one(characterTable, { fields: [characterTrainingTable.character], references: [characterTable.id] })
|
||||
}));
|
||||
export const characterLevelingRelation = relations(characterLevelingTable, ({ one }) => ({
|
||||
character: one(characterTable, { fields: [characterLevelingTable.character], references: [characterTable.id] })
|
||||
}));
|
||||
export const characterAbilitiesRelation = relations(characterAbilitiesTable, ({ one }) => ({
|
||||
character: one(characterTable, { fields: [characterAbilitiesTable.character], references: [characterTable.id] })
|
||||
}));
|
||||
export const characterChoicesRelation = relations(characterChoicesTable, ({ one }) => ({
|
||||
character: one(characterTable, { fields: [characterChoicesTable.character], references: [characterTable.id] })
|
||||
}));
|
||||
|
||||
export const campaignRelation = relations(campaignTable, ({ one, many }) => ({
|
||||
members: many(campaignMembersTable),
|
||||
characters: many(campaignCharactersTable),
|
||||
logs: many(campaignLogsTable),
|
||||
owner: one(usersTable, { fields: [campaignTable.owner], references: [usersTable.id], }),
|
||||
}));
|
||||
export const campaignMembersRelation = relations(campaignMembersTable, ({ one }) => ({
|
||||
campaign: one(campaignTable, { fields: [campaignMembersTable.id], references: [campaignTable.id], }),
|
||||
member: one(usersTable, { fields: [campaignMembersTable.user], references: [usersTable.id], })
|
||||
}));
|
||||
export const campaignCharacterRelation = relations(campaignCharactersTable, ({ one }) => ({
|
||||
campaign: one(campaignTable, { fields: [campaignCharactersTable.id], references: [campaignTable.id], }),
|
||||
character: one(characterTable, { fields: [campaignCharactersTable.character], references: [characterTable.id], }),
|
||||
}));
|
||||
export const campaignLogsRelation = relations(campaignLogsTable, ({ one }) => ({
|
||||
campaign: one(campaignTable, { fields: [campaignLogsTable.id], references: [campaignTable.id], }),
|
||||
character: one(campaignCharactersTable, { fields: [campaignLogsTable.from], references: [campaignCharactersTable.character], }),
|
||||
}));
|
||||
28
app/error.vue
Normal file
28
app/error.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Erreur {{ error?.statusCode }}</Title>
|
||||
</Head>
|
||||
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden justify-center items-center flex-col gap-4">
|
||||
<NuxtRouteAnnouncer/>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Icon icon="si:error-line" class="w-12 h-12 text-light-60 dark:text-dark-60"/>
|
||||
<div class="text-3xl">Une erreur est survenue.</div>
|
||||
</div>
|
||||
<pre class="text-center text-wrap">Erreur {{ error?.statusCode }}: {{ error?.message }}</pre>
|
||||
<button class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
|
||||
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
|
||||
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
|
||||
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 p-2" @click="handleError">Revenir en lieu sûr</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const props = defineProps({
|
||||
error: Object as () => NuxtError
|
||||
});
|
||||
|
||||
const handleError = () => clearError({ redirect: '/' });
|
||||
</script>
|
||||
108
app/layouts/default.vue
Normal file
108
app/layouts/default.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="flex flex-row w-full max-w-full h-full max-h-full" style="--sidebar-width: 300px">
|
||||
<div class="bg-light-0 dark:bg-dark-0 w-[var(--sidebar-width)] border-r border-light-30 dark:border-dark-30 flex flex-col gap-2">
|
||||
<NuxtLink class="flex flex-row items-center justify-center group gap-2 my-2" aria-label="Accueil" :to="{ name: 'index', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
<span class="text-xl font-semibold group-hover:text-light-70 dark:group-hover:text-dark-70">d[any]</span>
|
||||
</NuxtLink>
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent"></div>
|
||||
<div class="flex flex-col my-4 items-center justify-center gap-1 text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<NuxtLink class="hover:underline" :to="{ name: 'usage' }">Conditions d'utilisations</NuxtLink>
|
||||
Copyright Peaceultime - 2025
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 h-full w-[calc(100vw-var(--sidebar-width))]">
|
||||
<div class="flex flex-row border-b border-light-30 dark:border-dark-30 justify-between px-8">
|
||||
<div class="flex flex-row gap-16 items-center">
|
||||
<NavigationMenuRoot class="relative">
|
||||
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>
|
||||
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="absolute top-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30 py-2 z-20 flex flex-col">
|
||||
<NuxtLink :href="{ name: 'character-list' }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
|
||||
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
<div class="absolute top-full left-0 flex w-full justify-center">
|
||||
<NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" />
|
||||
</div>
|
||||
</NavigationMenuRoot>
|
||||
<NuxtLink :href="{ name: 'campaign' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
|
||||
</div>
|
||||
<div class="flex flex-row gap-16 items-center">
|
||||
<template v-if="!loggedIn">
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { TreeDOM } from '#shared/tree';
|
||||
import { Content, iconByType } from '#shared/content.util';
|
||||
import { dom, icon } from '#shared/dom.util';
|
||||
import { unifySlug } from '#shared/general.util';
|
||||
import { tooltip } from '#shared/floating.util';
|
||||
import { link, loading } from '#shared/components.util';
|
||||
|
||||
const open = ref(false);
|
||||
let tree: TreeDOM | undefined;
|
||||
const { loggedIn, user } = useUserSession();
|
||||
|
||||
const route = useRouter().currentRoute;
|
||||
const path = computed(() => route.value.params.path ? decodeURIComponent(unifySlug(route.value.params.path)) : undefined);
|
||||
|
||||
|
||||
const unmount = useRouter().afterEach((to, from, failure) => {
|
||||
if(failure)
|
||||
return;
|
||||
|
||||
to.name === 'explore-path' && (unifySlug(to.params.path ?? '').split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree?.toggle(tree.tree.search('path', e)[0], true));
|
||||
});
|
||||
|
||||
watch(route, () => {
|
||||
open.value = false;
|
||||
});
|
||||
|
||||
const treeParent = useTemplateRef('treeParent');
|
||||
onMounted(() => {
|
||||
if(treeParent.value)
|
||||
{
|
||||
treeParent.value.replaceChildren(loading('normal'));
|
||||
Content.ready.then(() => {
|
||||
tree = new TreeDOM((item, depth) => {
|
||||
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [dom('div', { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full cursor-pointer font-medium'], attributes: { 'data-private': item.private } }, [
|
||||
icon('radix-icons:chevron-right', { class: 'h-4 w-4 transition-transform absolute group-data-[state=open]:rotate-90', style: { 'left': `${depth / 1.5 - 1}em` } }),
|
||||
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
|
||||
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
|
||||
])]);
|
||||
}, (item, depth) => {
|
||||
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link([
|
||||
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
|
||||
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
|
||||
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
|
||||
], { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined )]);
|
||||
}, (item) => item.navigable);
|
||||
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree?.toggle(tree.tree.search('path', e)[0], true));
|
||||
|
||||
treeParent.value!.replaceChildren(tree.container);
|
||||
})
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unmount();
|
||||
})
|
||||
</script>
|
||||
8
app/layouts/login.vue
Normal file
8
app/layouts/login.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="w-full md:w-auto h-full border-e border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 md:p-8 xl:p-16 flex justify-center items-center">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="hidden md:block flex-auto h-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
3
app/layouts/null.vue
Normal file
3
app/layouts/null.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<slot></slot>
|
||||
</template>
|
||||
38
app/middleware/auth.global.ts
Normal file
38
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { hasPermissions } from "#shared/auth.util";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||
const { loggedIn, fetch, user } = useUserSession();
|
||||
const meta = to.meta;
|
||||
|
||||
await fetch()
|
||||
|
||||
if(!!meta.guestsGoesTo && !loggedIn.value)
|
||||
{
|
||||
return navigateTo(meta.guestsGoesTo);
|
||||
}
|
||||
else if(meta.requireAuth && !loggedIn.value)
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
else if(!!meta.usersGoesTo && loggedIn.value)
|
||||
{
|
||||
return navigateTo(meta.usersGoesTo);
|
||||
}
|
||||
else if(!!meta.validState && (!loggedIn.value || (user.value?.state ?? 0) === 0))
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
else if(!!meta.rights)
|
||||
{
|
||||
if(!user.value)
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
else if(!hasPermissions(user.value.permissions, meta.rights))
|
||||
{
|
||||
return abortNavigation({ statusCode: 401, message: 'Unauthorized', });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
3
app/pages/[...slug].vue
Normal file
3
app/pages/[...slug].vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
Page inconnue.
|
||||
</template>
|
||||
276
app/pages/admin/index.vue
Normal file
276
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
*
|
||||
* @return Formatted string.
|
||||
*/
|
||||
function textualFileSize(bytes: number, si: boolean = false, dp: number = 2) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
const units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
let u = -1;
|
||||
const r = 10**dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { format } from '#shared/general.util';
|
||||
import { iconByType } from '#shared/content.util';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
interface File
|
||||
{
|
||||
path: string;
|
||||
owner: number;
|
||||
title: string;
|
||||
type: "file" | "canvas" | "markdown" | 'folder';
|
||||
size: number;
|
||||
navigable: boolean;
|
||||
private: boolean;
|
||||
order: number;
|
||||
visit: number;
|
||||
timestamp: string;
|
||||
}
|
||||
interface User
|
||||
{
|
||||
id: number;
|
||||
username: string;
|
||||
state: number;
|
||||
session: {
|
||||
id: number;
|
||||
}[];
|
||||
data: {
|
||||
id: number;
|
||||
signin: string;
|
||||
lastTimestamp: string;
|
||||
logCount: number;
|
||||
};
|
||||
permission: string[];
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
rights: ['admin'],
|
||||
});
|
||||
|
||||
const { data: users } = useFetch('/api/admin/users', {
|
||||
transform: (users) => {
|
||||
//@ts-ignore
|
||||
users.forEach(e => e.permission = e.permission.map(p => p.permission));
|
||||
//@ts-ignore
|
||||
return users as User[];
|
||||
},
|
||||
});
|
||||
const { data: pages } = useFetch('/api/admin/pages');
|
||||
|
||||
const sorter = ref<((a: File, b: File) => number) | null>(null);
|
||||
const sortField = ref<keyof File | null>(null), sortOrder = ref<null | 'asc' | 'desc'>('asc');
|
||||
const sortedPage = ref([...pages.value ?? []]);
|
||||
|
||||
const permissionCopy = ref<string[]>([]);
|
||||
|
||||
watch([sortField, sortOrder, sorter], () => {
|
||||
sortedPage.value = (sorter.value === null ? ([...pages.value ?? []]) : sortedPage.value.sort(sorter.value))
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
function sort(field: keyof File, type: 'string' | 'number')
|
||||
{
|
||||
if(sortField.value === field)
|
||||
{
|
||||
if(sortOrder.value === 'asc')
|
||||
{
|
||||
sortOrder.value = 'desc';
|
||||
sorter.value = type === 'string' ? (a: File, b: File) => (b[field] as string).localeCompare(a[field] as string) : (a: File, b: File) => (b[field] as number) - (a[field] as number);
|
||||
}
|
||||
else
|
||||
{
|
||||
sortOrder.value = null;
|
||||
sortField.value = null;
|
||||
sorter.value = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sortField.value = field;
|
||||
sortOrder.value = 'asc';
|
||||
sorter.value = type === 'string' ? (a: File, b: File) => (a[field] as string).localeCompare(b[field] as string) : (a: File, b: File) => (a[field] as number) - (b[field] as number);
|
||||
}
|
||||
}
|
||||
async function editPermissions(user: User)
|
||||
{
|
||||
try
|
||||
{
|
||||
await $fetch(`/api/admin/user/${user.id}/permissions`, {
|
||||
method: 'POST',
|
||||
body: permissionCopy.value,
|
||||
});
|
||||
user.permission = permissionCopy.value;
|
||||
Toaster.add({
|
||||
duration: 10000, type: 'success', content: 'Permissions mises à jour.', timer: true,
|
||||
});
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
Toaster.add({
|
||||
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function logout(user: User)
|
||||
{
|
||||
try
|
||||
{
|
||||
await $fetch(`/api/admin/user/${user.id}/logout`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
user.session.length = 0;
|
||||
|
||||
Toaster.add({
|
||||
duration: 10000, type: 'success', content: 'L\'utilisateur vient d\'être déconnecté.', timer: true,
|
||||
});
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
Toaster.add({
|
||||
duration: 10000, type: 'error', content: (e as any).message, timer: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Administration</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col p-4">
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
|
||||
<NuxtLink :to="{ name: 'admin-jobs' }"><Button>Jobs</Button></NuxtLink>
|
||||
</div>
|
||||
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<Collapsible v-if=users :label="`Utilisateurs (${users.length})`">
|
||||
<div class="flex flex-1 mt-2">
|
||||
<table class="border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Utilisateur</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Inscription</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Dernière connexion</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Mail</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Sessions</th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1">Permissions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="font-normal">
|
||||
<tr v-for="user in users">
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-32 truncate"><NuxtLink :to="{ name: 'user-id', params: { id: user.id } }" class="hover:text-accent-purple font-bold" :title="user.username">{{ user.username }}</NuxtLink></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.signin), 'dd/MM/yyyy') }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center">{{ format(new Date(user.data.lastTimestamp), 'dd/MM/yyyy HH:mm:ss') }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><Icon :class="{ 'text-light-red dark:text-dark-red': user.state === 0, 'text-light-green dark:text-dark-green': user.state !== 0 }" :icon="user.state === 0 ? `radix-icons:cross-2` : `radix-icons:check`" /></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||
<DialogRoot>
|
||||
<DialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold cursor-pointer">{{ user.session.length }}</span></DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<DialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<DialogTitle class="text-3xl font-light relative -top-2">Deconnecter l'utilisateur ?
|
||||
</DialogTitle>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<DialogClose asChild><Button>Non</Button></DialogClose>
|
||||
<DialogClose asChild><Button @click="() => logout(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Oui</Button></DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1">
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger asChild><span class="text-accent-blue hover:text-accent-purple font-bold" @click="permissionCopy = [...user.permission]">{{ user.permission.length }}</span></AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle class="text-3xl font-light relative -top-2">Permissions de {{ user.username }}</AlertDialogTitle>
|
||||
<AlertDialogDescription><TagsInput v-model="permissionCopy" /></AlertDialogDescription>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => editPermissions(user)" class="border-light-green dark:border-dark-green hover:border-light-green dark:hover:border-dark-green hover:bg-light-greenBack dark:hover:bg-dark-greenBack text-light-green dark:text-dark-green focus:shadow-light-green dark:focus:shadow-dark-green">Modifier</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Collapsible v-if=pages :label="`Pages (${pages.length})`">
|
||||
<div class="flex flex-1 mt-2">
|
||||
<table class="border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Page</span><span @click="() => sort('title', 'string')"><Icon :icon="sortField === 'title' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Type</span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Propriétaire</span><span @click="() => sort('owner', 'number')"><Icon :icon="sortField === 'owner' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Status</span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Taille</span><span @click="() => sort('size', 'number')"><Icon :icon="sortField === 'size' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Visites</span><span @click="() => sort('visit', 'number')"><Icon :icon="sortField === 'visit' ? sortOrder === 'asc' ? 'radix-icons:chevron-down' : 'radix-icons:chevron-up' : 'radix-icons:caret-sort'" /></span></div></th>
|
||||
<th class="border border-light-35 dark:border-dark-35 px-2 py-1"><div class="flex justify-center items-center gap-2"><span>Actions</span></div></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="font-normal">
|
||||
<DialogRoot>
|
||||
<tr v-for="page in sortedPage" :id="page.path">
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 max-w-48 truncate"><NuxtLink :to="{ name: 'explore-path', params: { path: page.path } }" class="hover:text-accent-purple font-bold" :title="page.title">{{ page.title }}</NuxtLink></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1"><Icon :icon="iconByType[page.type]" /></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-sm text-light-70 dark:text-dark-70 text-center max-w-32 truncate"><span :title=" users?.find(e => e.id === page.owner)?.username ?? 'Inconnu'">{{ users?.find(e => e.id === page.owner)?.username ?? "Inconnu" }}</span></td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 ">
|
||||
<div class="flex gap-2 justify-center">
|
||||
<span>
|
||||
<Icon v-if="page.private" icon="radix-icons:lock-closed" />
|
||||
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:lock-open-2" />
|
||||
</span>
|
||||
<span>
|
||||
<Icon v-if="page.navigable" icon="radix-icons:eye-open" />
|
||||
<Icon v-else class="text-light-50 dark:text-dark-50" icon="radix-icons:eye-none" />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ textualFileSize(page.size) }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center">{{ page.visit }}</td>
|
||||
<td class="border border-light-35 dark:border-dark-35 px-2 py-1 text-center"><div class="flex justify-center items-center"><NuxtLink :to="{ name: 'explore-edit', hash: '#' + page.path }"><Icon icon="radix-icons:pencil-1" /></NuxtLink></div></td>
|
||||
</tr>
|
||||
</DialogRoot>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
92
app/pages/admin/jobs.vue
Normal file
92
app/pages/admin/jobs.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
const mailSchema = z.object({
|
||||
to: z.string().email(),
|
||||
template: z.string(),
|
||||
data: z.string(),
|
||||
});
|
||||
|
||||
const schemaList: Record<string, z.ZodObject<any> | null> = {
|
||||
'pull': null,
|
||||
'push': null,
|
||||
'mail': mailSchema,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod/v4';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
rights: ['admin'],
|
||||
})
|
||||
const job = ref<string>('');
|
||||
|
||||
const payload = reactive<Record<string, any>>({
|
||||
data: JSON.stringify({ username: "Peaceultime", id: 1, timestamp: Date.now() }),
|
||||
to: 'clem31470@gmail.com',
|
||||
});
|
||||
const data = ref(), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), success = ref(false), error = ref<Error | null>();
|
||||
async function fetch()
|
||||
{
|
||||
status.value = 'pending';
|
||||
data.value = null;
|
||||
error.value = null;
|
||||
success.value = false;
|
||||
|
||||
try
|
||||
{
|
||||
const schema = schemaList[job.value];
|
||||
|
||||
if(schema)
|
||||
{
|
||||
const parsedPayload = schema.parse(payload);
|
||||
}
|
||||
|
||||
data.value = await $fetch(`/api/admin/jobs/${job.value}`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
status.value = 'success';
|
||||
error.value = null;
|
||||
success.value = true;
|
||||
|
||||
Toaster.add({ duration: 10000, content: data.value ?? 'Job executé avec succès', type: 'success', timer: true, });
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
status.value = 'error';
|
||||
error.value = e as Error;
|
||||
success.value = false;
|
||||
|
||||
Toaster.add({ duration: 10000, content: error.value.message, type: 'error', timer: true, });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Administration</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col justify-start items-center p-4">
|
||||
<div class="flex flex-row justify-between items-center gap-8">
|
||||
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||
<h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
|
||||
</div>
|
||||
<div class="flex flex-row w-full gap-8">
|
||||
<Select label="Job" v-model="job">
|
||||
<SelectItem label="Récupérer les données d'Obsidian" value="pull" />
|
||||
<SelectItem label="Envoyer les données dans Obsidian" value="push" disabled />
|
||||
<SelectItem label="Envoyer un mail de test" value="mail" />
|
||||
</Select>
|
||||
<Select v-if="job === 'mail'" v-model="payload.template" label="Modèle" class="w-full" ><SelectItem label="Inscription" value="registration" /></Select>
|
||||
</div>
|
||||
<div v-if="job === 'mail'" class="flex justify-center items-center flex-col">
|
||||
<TextInput label="Destinataire" class="w-full" v-model="payload.to" />
|
||||
<textarea v-model="payload.data" class="w-[640px] bg-light-20 dark:bg-dark-20 border border-light-35 dark:border-dark-35 outline-none m-2 px-2"></textarea>
|
||||
</div>
|
||||
<Button class="self-center" @click="() => !!job && fetch()" :loading="status === 'pending'">
|
||||
<span>Executer</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
3
app/pages/campaign/[id]/edit.client.vue
Normal file
3
app/pages/campaign/[id]/edit.client.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
79
app/pages/campaign/[id]/index.client.vue
Normal file
79
app/pages/campaign/[id]/index.client.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { unifySlug } from '#shared/general.util';
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
});
|
||||
|
||||
const id = unifySlug(useRoute().params.id ?? '');
|
||||
const { user, loggedIn } = useUserSession();
|
||||
const { data: campaign, error, status } = await useFetch(`/api/campaign/${id}`);
|
||||
const copied = ref(false);
|
||||
const stopCopy = useDebounceFn(() => copied.value = false, 5000);
|
||||
|
||||
const websocket = useWebSocket(`wss://localhost:3000/ws/campaign/${id}`, { heartbeat: true });
|
||||
|
||||
async function copyLink()
|
||||
{
|
||||
await navigator.clipboard.writeText(`https://d-any.com/campaign/join/${ encodeURIComponent(campaign.value!.link) }`);
|
||||
copied.value = true;
|
||||
stopCopy();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="status === 'pending'" class="flex flex-col items-center justify-center w-full h-full">
|
||||
<Loading />
|
||||
</div>
|
||||
<div class="flex flex-col w-full h-full items-center gap-4" v-else-if="campaign && status === 'success'">
|
||||
<div class="flex flex-row gap-8 items-center">
|
||||
<div class="flex flex-row gap-8 items-baseline">
|
||||
<span class="text-2xl font-semibold">{{ campaign.name }}</span>
|
||||
<span class="italic">MJ: {{ campaign.owner.username }}</span>
|
||||
</div>
|
||||
<div class="border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2">
|
||||
<pre class="ps-1 w-[400px] truncate">https://d-any.com/campaign/join/{{ encodeURIComponent(campaign.link) }}</pre>
|
||||
<div class="cursor-pointer border border-light-35 dark:border-dark-35 hover:bg-light-30 dark:hover:bg-dark-30 hover:border-light-50 dark:hover:border-dark-50 p-1" @click="copyLink">
|
||||
<Icon v-if="!copied" icon="radix-icons:clipboard" class="w-4 h-4"/>
|
||||
<Icon v-else icon="radix-icons:check" class="w-4 h-4"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cursor-pointer border border-light-35 dark:border-dark-35 hover:bg-light-30 dark:hover:bg-dark-30 hover:border-light-50 dark:hover:border-dark-50 p-1" @click="() => websocket.open()">{{ websocket.status }}</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-4 flex-1">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row items-center gap-4"><span class="text-lg font-bold tracking-tight">Joueurs</span><span class="border-b border-dashed border-light-35 dark:border-dark-35 w-full"></span></div>
|
||||
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-64">
|
||||
<template v-if="campaign.members.length > 0">
|
||||
<div v-for="player of campaign.members" class="flex flex-col py-1 my-1">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<span>{{ player.member?.username }}</span>
|
||||
<Tooltip message="Absent" side="right" :delay="0"><span class="rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed"></span></Tooltip>
|
||||
</div>
|
||||
<template v-if="player.characters.length > 0">
|
||||
<div class="flex flex-col border-l-2 border-light-35 dark:border-dark-35 ps-2">
|
||||
<div class="flex flex-row items-center justify-between" v-for="character of player.characters">
|
||||
<span>{{ character.character.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm italic text-light-70 dark:text-dark-70">Sans personnage</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm italic py-2 text-center">Invitez des joueurs via le lien d'invitation</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-l border-light-35 dark:border-dark-35"></div>
|
||||
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col" v-else-if="status === 'error'">
|
||||
|
||||
</div>
|
||||
</template>
|
||||
171
app/pages/campaign/index.client.vue
Normal file
171
app/pages/campaign/index.client.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
});
|
||||
|
||||
const { user, loggedIn } = useUserSession();
|
||||
const { data: campaigns, error, status } = await useFetch(`/api/campaign`);
|
||||
const archives = computed(() => campaigns.value?.filter(e => e.status === 'ARCHIVED'));
|
||||
const valids = computed(() => campaigns.value?.filter(e => e.status !== 'ARCHIVED'));
|
||||
|
||||
async function leaveCampaign(id: number)
|
||||
{
|
||||
try
|
||||
{
|
||||
await useRequestFetch()(`/api/campaign/${id}/leave`, { method: 'POST', });
|
||||
campaigns.value = campaigns.value?.filter(e => e.id !== id);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
Toaster.add({ duration: 10000, content: (e as Error).message ?? e, title: 'Une erreur est survenue.', type: 'error', timer: true, });
|
||||
}
|
||||
}
|
||||
async function removeCampaign(id: number)
|
||||
{
|
||||
try
|
||||
{
|
||||
await useRequestFetch()(`/api/campaign/${id}`, { method: 'DELETE', });
|
||||
campaigns.value = campaigns.value?.filter(e => e.id !== id);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
Toaster.add({ duration: 10000, content: (e as Error).message ?? e, title: 'Une erreur est survenue.', type: 'error', timer: true, });
|
||||
}
|
||||
}
|
||||
function create()
|
||||
{
|
||||
useRequestFetch()('/api/campaign', {
|
||||
method: 'POST',
|
||||
body: { id: 'new', name: 'Test', description: '', joinby: 'link' },
|
||||
}).then(() => Toaster.add({ duration: 8000, content: 'Campagne créée', type: 'info' })).catch((e) => Toaster.add({ duration: 8000, title: 'Une erreur est survenue', content: e, type: 'error' }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Mes campagnes</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
|
||||
<Loading size="large" />
|
||||
</div>
|
||||
<template v-else-if="status === 'success' && loggedIn && user">
|
||||
<div v-if="campaigns && campaigns.length > 0" class="flex flex-col gap-4">
|
||||
<div v-if="valids && valids.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
|
||||
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of valids">
|
||||
<NuxtLink :to="{ name: 'campaign-id', params: { id: campaign.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-8 ps-4 items-center">
|
||||
<div class="flex flex-1 flex-col gap-2 justify-center">
|
||||
<span class="text-lg font-bold group-hover:text-accent-blue">{{ campaign.name }}</span>
|
||||
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
|
||||
<div class="flex flex-row flex-1 items-stretch gap-4">
|
||||
<span class="text-sm">{{ campaign.members.length }} joueur{{ campaign.members.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="flex justify-around items-center py-2 px-4 gap-4">
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger>
|
||||
<span class="text-sm font-bold text-light-red dark:text-dark-red">{{ user.id !== campaign.owner.id ? 'Quitter' : 'Supprimer' }}</span>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle class="text-3xl font-light relative -top-2">Vous vous appretez à {{ user.id !== campaign.owner.id ? 'quitter' : 'supprimer' }} "{{ campaign.name }}". Etes vous sûr ?</AlertDialogTitle>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => user?.id !== campaign.owner.id ? leaveCampaign(campaign.id) : removeCampaign(campaign.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of campaigns.filter(e => e.status === 'ARCHIVED')">
|
||||
<NuxtLink :to="{ name: 'campaign-id', params: { id: campaign.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-8 ps-4 items-center">
|
||||
<div class="flex flex-1 flex-col gap-2 justify-center">
|
||||
<span class="text-lg font-bold group-hover:text-accent-blue">{{ campaign.name }}</span>
|
||||
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
|
||||
<div class="flex flex-row flex-1 items-stretch gap-4">
|
||||
<span class="text-sm">{{ campaign.members.length }} joueur{{ campaign.members.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="archives && archives.length > 0" class="flex flex-row w-full gap-8 justify-center items-center"><span class="border-t border-light-35 dark:border-dark-35 flex-1"></span><span class="text-lg font-semibold">Archives</span><span class="border-t border-light-35 dark:border-dark-35 flex-1"></span></div>
|
||||
<div v-if="archives && archives.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
|
||||
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of archives">
|
||||
<NuxtLink :to="{ name: 'campaign-id', params: { id: campaign.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-8 ps-4 items-center">
|
||||
<div class="flex flex-1 flex-col gap-2 justify-center">
|
||||
<span class="text-lg font-bold group-hover:text-accent-blue">{{ campaign.name }}</span>
|
||||
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
|
||||
<div class="flex flex-row flex-1 items-stretch gap-4">
|
||||
<span class="text-sm">{{ campaign.members.length }} joueur{{ campaign.members.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="flex justify-around items-center py-2 px-4 gap-4">
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger>
|
||||
<span class="text-sm font-bold text-light-red dark:text-dark-red">{{ user.id !== campaign.owner.id ? 'Quitter' : 'Supprimer' }}</span>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle class="text-3xl font-light relative -top-2">Vous vous appretez à {{ user.id !== campaign.owner.id ? 'quitter' : 'supprimer' }} "{{ campaign.name }}". Etes vous sûr ?</AlertDialogTitle>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => user?.id !== campaign.owner.id ? leaveCampaign(campaign.id) : removeCampaign(campaign.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of campaigns.filter(e => e.status === 'ARCHIVED')">
|
||||
<NuxtLink :to="{ name: 'campaign-id', params: { id: campaign.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-8 ps-4 items-center">
|
||||
<div class="flex flex-1 flex-col gap-2 justify-center">
|
||||
<span class="text-lg font-bold group-hover:text-accent-blue">{{ campaign.name }}</span>
|
||||
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
|
||||
<div class="flex flex-row flex-1 items-stretch gap-4">
|
||||
<span class="text-sm">{{ campaign.members.length }} joueur{{ campaign.members.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 items-center flex-1">
|
||||
<span class="text-lg font-bold">Vous n'avez pas encore rejoint de campagne</span>
|
||||
<div class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
|
||||
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
|
||||
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
|
||||
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" @click="create">Créer ma campagne</div>
|
||||
<!-- <NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
|
||||
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
|
||||
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
|
||||
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'campaign-id-edit', params: { id: 'new' } }">Créer ma campagne</NuxtLink> -->
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<span>Erreur de chargement</span>
|
||||
<span>{{ error?.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
27
app/pages/character/[id]/edit.client.vue
Normal file
27
app/pages/character/[id]/edit.client.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { CharacterBuilder } from '#shared/character.util';
|
||||
import { unifySlug } from '#shared/general.util';
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
});
|
||||
const id = unifySlug(useRouter().currentRoute.value.params.id ?? "new");
|
||||
const container = useTemplateRef('container');
|
||||
|
||||
onMounted(() => {
|
||||
queueMicrotask(() => {
|
||||
if(container.value)
|
||||
{
|
||||
const builder = new CharacterBuilder(container.value, id === 'new' ? undefined : id);
|
||||
|
||||
useShortcuts({
|
||||
"Meta_S": () => builder.save(false),
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 max-w-full flex-col align-center" ref="container"></div>
|
||||
</template>
|
||||
38
app/pages/character/[id]/index.client.vue
Normal file
38
app/pages/character/[id]/index.client.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import characterConfig from '#shared/character-config.json';
|
||||
import { unifySlug } from '#shared/general.util';
|
||||
import type { CharacterConfig } from '~/types/character';
|
||||
import { CharacterSheet } from '#shared/character.util';
|
||||
|
||||
/*
|
||||
text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red
|
||||
text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue
|
||||
text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow
|
||||
text-light-orange dark:text-dark-orange border-light-orange dark:border-dark-orange bg-light-orange dark:bg-dark-orange
|
||||
text-light-indigo dark:text-dark-indigo border-light-indigo dark:border-dark-indigo bg-light-indigo dark:bg-dark-indigo
|
||||
text-light-lime dark:text-dark-lime border-light-lime dark:border-dark-lime bg-light-lime dark:bg-dark-lime
|
||||
text-light-green dark:text-dark-green border-light-green dark:border-dark-green bg-light-green dark:bg-dark-green
|
||||
text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow
|
||||
text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple
|
||||
*/
|
||||
|
||||
const config = characterConfig as CharacterConfig;
|
||||
|
||||
const id = useRouter().currentRoute.value.params.id ? unifySlug(useRouter().currentRoute.value.params.id!) : undefined;
|
||||
const { user } = useUserSession();
|
||||
const container = useTemplateRef('container');
|
||||
|
||||
onMounted(() => {
|
||||
queueMicrotask(() => {
|
||||
if(container.value && id)
|
||||
{
|
||||
const character = new CharacterSheet(id, user);
|
||||
container.value.appendChild(character.container);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 w-full h-full items-start justify-center" ref="container"></div>
|
||||
</template>
|
||||
96
app/pages/character/index.client.vue
Normal file
96
app/pages/character/index.client.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import characterConfig from '#shared/character-config.json';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
import type { CharacterConfig } from '~/types/character';
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
})
|
||||
const { data: characters, error, status } = await useFetch(`/api/character`);
|
||||
const config = characterConfig as CharacterConfig;
|
||||
|
||||
async function deleteCharacter(id: number)
|
||||
{
|
||||
status.value = "pending";
|
||||
await useRequestFetch()(`/api/character/${id}`, { method: 'delete' });
|
||||
status.value = "success";
|
||||
Toaster.add({ content: 'Personnage supprimé', type: 'info', duration: 25000, timer: true, });
|
||||
characters.value = characters.value?.filter(e => e.id !== id);
|
||||
}
|
||||
async function duplicateCharacter(id: number)
|
||||
{
|
||||
status.value = "pending";
|
||||
const newId = await useRequestFetch()(`/api/character/${id}/duplicate`, { method: 'post' });
|
||||
status.value = "success";
|
||||
Toaster.add({ content: 'Personnage dupliqué', type: 'info', duration: 25000, timer: true, });
|
||||
useRouter().push({ name: 'character-id', params: { id: newId } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Mes personnages</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
|
||||
<Loading size="large" />
|
||||
</div>
|
||||
<template v-else-if="status === 'success'">
|
||||
<div v-if="characters && characters.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
|
||||
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
|
||||
<NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-8 ps-4 items-center">
|
||||
<div class="flex flex-1 flex-col gap-2 justify-center">
|
||||
<span class="text-lg font-bold group-hover:text-accent-blue">{{ character.name }}</span>
|
||||
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
|
||||
<div class="flex flex-row flex-1 items-stretch gap-4">
|
||||
<span class="text-sm">Niveau {{ character.level }}</span>
|
||||
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
|
||||
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="flex justify-around items-center py-2 px-4 gap-4">
|
||||
<NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Editer</NuxtLink>
|
||||
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
|
||||
<NuxtLink @click="duplicateCharacter(character.id)" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Dupliquer</NuxtLink>
|
||||
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger>
|
||||
<span class="text-sm font-bold text-light-red dark:text-dark-red">Supprimer</span>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 items-center flex-1">
|
||||
<span class="text-lg font-bold">Vous n'avez pas encore de personnage</span>
|
||||
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
|
||||
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
|
||||
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
|
||||
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
|
||||
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
|
||||
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
|
||||
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
|
||||
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-list' }">Qu'ont fait les autres ?</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<span>Erreur de chargement</span>
|
||||
<span>{{ error?.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
app/pages/character/list.client.vue
Normal file
49
app/pages/character/list.client.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import characterConfig from '#shared/character-config.json';
|
||||
import type { CharacterConfig } from '~/types/character';
|
||||
const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } });
|
||||
const config = characterConfig as CharacterConfig;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Liste des personnages</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
|
||||
<Loading size="large" />
|
||||
</div>
|
||||
<template v-else-if="status === 'success'">
|
||||
<div v-if="characters && characters.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
|
||||
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
|
||||
<NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 p-2 flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-8 ps-4 items-center">
|
||||
<div class="flex flex-1 flex-col gap-2 justify-center">
|
||||
<span class="text-lg font-bold group-hover:text-accent-blue">{{ character.name }}</span>
|
||||
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
|
||||
<div class="flex flex-row flex-1 items-stretch gap-4">
|
||||
<span class="text-sm">Niveau {{ character.level }}</span>
|
||||
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
|
||||
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2 items-center flex-1">
|
||||
<span class="text-lg font-bold">Il n'existe pas encore de personnage public</span>
|
||||
Soyez le premier à partager vos créations !
|
||||
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
|
||||
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
|
||||
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
|
||||
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<span>Erreur de chargement</span>
|
||||
<span>{{ error?.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
26
app/pages/character/manage.client.vue
Normal file
26
app/pages/character/manage.client.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { HomebrewBuilder } from '#shared/feature.util';
|
||||
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
});
|
||||
|
||||
const container = useTemplateRef('container');
|
||||
|
||||
onMounted(() => {
|
||||
queueMicrotask(() => {
|
||||
if(container.value)
|
||||
{
|
||||
const builder = new HomebrewBuilder(container.value);
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Edition de données</Title>
|
||||
</Head>
|
||||
<div ref="container" class="flex flex-1 max-w-full flex-col gap-8 justify-start items-center px-8 w-full"></div>
|
||||
</template>
|
||||
23
app/pages/explore/[...path].vue
Normal file
23
app/pages/explore/[...path].vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="flex flex-1 justify-start items-start" ref="element">
|
||||
<Head>
|
||||
<Title>d[any] - {{ overview?.title ?? "Erreur" }}</Title>
|
||||
</Head>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Content } from '#shared/content.util';
|
||||
import { unifySlug } from '#shared/general.util';
|
||||
|
||||
const element = useTemplateRef('element'), overview = ref();
|
||||
const route = useRouter().currentRoute;
|
||||
const path = computed(() => unifySlug(route.value.params.path ?? ''));
|
||||
|
||||
onMounted(async () => {
|
||||
if(element.value && path.value)
|
||||
{
|
||||
overview.value = await Content.render(element.value, path.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
111
app/pages/explore/edit/index.vue
Normal file
111
app/pages/explore/edit/index.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Modification</Title>
|
||||
</Head>
|
||||
<div class="flex flex-row w-full max-w-full h-full max-h-full xl:-mx-12 xl:-my-8 lg:-mx-8 lg:-my-6 -mx-6 -my-3" style="--sidebar-width: 300px">
|
||||
<div class="bg-light-0 dark:bg-dark-0 w-[var(--sidebar-width)] border-r border-light-30 dark:border-dark-30 flex flex-col gap-2">
|
||||
<NuxtLink class="flex flex-row items-center justify-center group gap-2 my-2" aria-label="Accueil" :to="{ name: 'index', force: true }">
|
||||
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
|
||||
<Avatar src="/logo.light.svg" class="block dark:hidden" />
|
||||
<span class="text-xl font-semibold group-hover:text-light-70 dark:group-hover:text-dark-70">d[any]</span>
|
||||
</NuxtLink>
|
||||
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="tree"></div>
|
||||
<div class="flex flex-col my-4 items-center justify-center gap-1 text-xs text-light-60 dark:text-dark-60">
|
||||
<NuxtLink class="hover:underline" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
|
||||
<NuxtLink class="hover:underline" :to="{ name: 'usage' }">Conditions d'utilisations</NuxtLink>
|
||||
Copyright Peaceultime - 2025
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 h-full w-[calc(100vw-var(--sidebar-width))]">
|
||||
<div class="flex flex-row border-b border-light-30 dark:border-dark-30 justify-between px-8">
|
||||
<div class="flex flex-row gap-16 items-center">
|
||||
<NavigationMenuRoot class="relative">
|
||||
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>
|
||||
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="absolute top-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30 py-2 z-20 flex flex-col">
|
||||
<NuxtLink :href="{ name: 'character-list' }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
|
||||
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
<div class="absolute top-full left-0 flex w-full justify-center">
|
||||
<NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" />
|
||||
</div>
|
||||
</NavigationMenuRoot>
|
||||
<NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
|
||||
</div>
|
||||
<div class="flex flex-row gap-16 items-center">
|
||||
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-row max-h-full overflow-hidden" ref="container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Content, Editor } from '#shared/content.util';
|
||||
import { button, loading } from '#shared/components.util';
|
||||
import { dom, icon } from '#shared/dom.util';
|
||||
import { modal, tooltip } from '#shared/floating.util';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
definePageMeta({
|
||||
rights: ['admin', 'editor'],
|
||||
layout: 'null',
|
||||
});
|
||||
|
||||
const { user } = useUserSession();
|
||||
const tree = useTemplateRef('tree'), container = useTemplateRef('container');
|
||||
let editor: Editor;
|
||||
|
||||
function pull()
|
||||
{
|
||||
Content.pull().then(e => {
|
||||
Toaster.add({ type: 'success', content: 'Données mises à jour avec succès.', timer: true, duration: 7500 });
|
||||
}).catch(e => {
|
||||
Toaster.add({ type: 'success', content: 'Une erreur est survenue durant la récupération des données.', timer: true, duration: 7500 });
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
function push()
|
||||
{
|
||||
const { close } = modal([dom('div', { class: 'flex flex-col gap-4 justify-center items-center' }, [ dom('div', { class: 'text-xl', text: 'Mise à jour des données' }), loading('large') ])], { priority: false, closeWhenOutside: true, });
|
||||
Content.push().then(e => {
|
||||
close();
|
||||
Toaster.add({ type: 'success', content: 'Données mises à jour avec succès.', timer: true, duration: 7500 });
|
||||
}).catch(e => {
|
||||
close();
|
||||
Toaster.add({ type: 'success', content: 'Une erreur est survenue durant l\'enregistrement des données.', timer: true, duration: 7500 });
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if(tree.value && container.value)
|
||||
{
|
||||
const load = loading('normal');
|
||||
tree.value.appendChild(load);
|
||||
|
||||
const content = dom('div', { class: 'flex flex-row justify-start items-center gap-4 p-2' }, [
|
||||
tooltip(button(icon('ph:cloud-arrow-down', { height: 20, width: 20 }), pull, 'p-1'), 'Actualiser', 'top'),
|
||||
tooltip(button(icon('ph:cloud-arrow-up', { height: 20, width: 20 }), push, 'p-1'), 'Enregistrer', 'top'),
|
||||
])
|
||||
|
||||
tree.value.insertBefore(content, load);
|
||||
|
||||
editor = new Editor();
|
||||
|
||||
Content.ready.then(() => tree.value!.replaceChild(editor.tree.container, load));
|
||||
container.value.appendChild(editor.container);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor?.unmount();
|
||||
});
|
||||
</script>
|
||||
6
app/pages/index.vue
Normal file
6
app/pages/index.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Accueil</Title>
|
||||
</Head>
|
||||
<h1 class="text-5xl font-thin font-mono">Bienvenue</h1>
|
||||
</template>
|
||||
36
app/pages/legal.vue
Normal file
36
app/pages/legal.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Mentions légales</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col max-w-[1200px] p-16">
|
||||
<h3 class="text-xl font-bold">Mentions Légales</h3>
|
||||
<h4 class="text-lg font-semibold">Collecte et Traitement des Données Personnelles</h4>
|
||||
Ce site collecte des données personnelles durant l'inscription et des données anonymes durant la navigation sur
|
||||
le site dans un but de collecte statistiques.<br />
|
||||
|
||||
Conformément à la réglementation en vigueur, vous disposez d'un droit d'accès, de rectification et de
|
||||
suppression de vos données personnelles. <br />
|
||||
|
||||
Pour exercer ces droits, vous pouvez vous rendre dans votre profil et selectionner l'option "Supprimer mon
|
||||
compte" qui garanti une suppression de l'intégralité de vos données personnelles.<br /><br />
|
||||
|
||||
<h4 class="text-lg font-semibold">Utilisation des Cookies</h4>
|
||||
Ce site utilise des cookies uniquement pour maintenir la connexion des utilisateurs et faciliter leur navigation
|
||||
lors de chaque visite. Aucune information de suivi ou de profilage n'est réalisée. Ces cookies sont essentiels
|
||||
au fonctionnement du site et ne nécessitent pas de consentement préalable. <br />
|
||||
|
||||
Vous pouvez gérer les cookies en configurant les paramètres de votre navigateur, mais la désactivation de ces
|
||||
cookies pourrait affecter votre expérience de navigation.<br /><br />
|
||||
|
||||
<h4 class="text-lg font-semibold">Limitation de Responsabilité</h4>
|
||||
Les informations publiées sur ce site sont fournies à titre indicatif et peuvent contenir des erreurs. <br />
|
||||
L'éditeur décline toute responsabilité quant à l'usage qui pourrait être fait de ces informations.<br /><br />
|
||||
|
||||
<h4 class="text-lg font-semibold">Propriété Intellectuelle</h4>
|
||||
Tous les contenus présents sur ce site (textes, images, logos, etc.) sont protégés par les lois en vigueur
|
||||
sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable
|
||||
est interdite. <br /><br />
|
||||
|
||||
<span class="text-light-60 dark:text-dark-60 text-lg">© Copyright Peaceultime - 2024</span>
|
||||
</div>
|
||||
</template>
|
||||
45
app/pages/usage.vue
Normal file
45
app/pages/usage.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Mentions légales</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col max-w-[1200px] p-16">
|
||||
<h3 class="text-xl font-bold">Conditions Générales d'Utilisation du site d-any.com</h3>
|
||||
<h4 class="text-lg font-semibold py-2">1. Objet</h4>
|
||||
Le site d-any.com offre un service en ligne dédié au jeu de rôle comprenant une section de règles officielles maintenues par l'administrateur, une section permettant la création de personnages
|
||||
publics ou privés et une section de campagnes visant à rassembler plusieurs joueurs pour faire interagir leurs personnages. L'utilisation du site implique l'acceptation pleine et entière des présentes conditions. <br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">2. Accès et fonctionnement</h4>
|
||||
L'accès au site est gratuit. L'interaction entre utilisateurs est strictement limitée aux personnages et joueurs participant à une même campagne partagée. Aucun contact direct ni interaction n'est possible en dehors de cette structure.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">3. Création et gestion des personnages</h4>
|
||||
Les utilisateurs peuvent créer des personnages publics, visibles par tous les membres des campagnes partagées, ou privés, visibles uniquement par leur créateur.
|
||||
Les utilisateurs sont responsables du contenu des personnages qu'ils créent. Ils s'engagent à ne pas créer ou publier des personnages portant atteinte à la dignité, contenant des propos discriminatoires, diffamatoires, obscènes ou illicites.
|
||||
L'administrateur du site se réserve le droit de supprimer ou masquer tout personnage en infraction avec ces règles.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">4. Règles du jeu</h4>
|
||||
Les règles officielles du jeu, rédigées et entretenues par l'administrateur, doivent être respectées par tous les utilisateurs dans la création et le déroulement des campagnes.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">5. Interaction en campagne</h4>
|
||||
Les communications et interactions entre joueurs et personnages sont strictement limitées aux campagnes partagées.
|
||||
Toute interaction dans ces cadres doit respecter les règles de respect, de courtoisie et de fair-play.
|
||||
Tout comportement abusif, harcèlement, propos haineux ou toute forme de contenu illicite est prohibé et pourra entraîner des sanctions, incluant la suppression de comptes ou personnages.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">6. Propriété intellectuelle</h4>
|
||||
Les règles, outils, et contenus hébergés sur le site sont la propriété de l'administrateur ou des auteurs respectifs.
|
||||
Les personnages créés appartiennent à leurs auteurs, sous réserve du respect des droits d'auteur liés au jeu original et de la charte du site.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">7. Données personnelles</h4>
|
||||
Les données collectées se limitent à celles nécessaires au fonctionnement du site. Toute donnée personnelle est traitée conformément à la réglementation en vigueur et peut être modifiée ou supprimée sur demande.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">8. Responsabilité</h4>
|
||||
L'administrateur ne pourra être tenu responsable des usages faits par les utilisateurs des personnages publics ou des interactions au sein des campagnes. L'éditeur décline toute responsabilité en cas d'abus
|
||||
entre joueurs ou de contenu illégal diffusé par un utilisateur.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">9. Modification des conditions</h4>
|
||||
Ces conditions peuvent être modifiées à tout moment par l'administrateur. Les utilisateurs seront informés des modifications via le site et l'usage continu vaudra acceptation des nouvelles conditions.<br/><br/>
|
||||
|
||||
<h4 class="text-lg font-semibold py-2">10. Droit applicable</h4>
|
||||
Les présentes conditions sont soumises au droit français. Tout litige sera porté devant les tribunaux compétents.<br/><br/>
|
||||
<div class="py-32"></div>
|
||||
</div>
|
||||
</template>
|
||||
18
app/pages/user/(automatic)/mailvalidated.vue
Normal file
18
app/pages/user/(automatic)/mailvalidated.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Validation de votre adresse mail</Title>
|
||||
</Head>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<h2 class="text-2xl font-bold">Votre compte a été validé ! 🎉</h2>
|
||||
<div class="flex flex-row gap-8">
|
||||
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button>
|
||||
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
})
|
||||
</script>
|
||||
45
app/pages/user/(automatic)/reset-password.vue
Normal file
45
app/pages/user/(automatic)/reset-password.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Reinitialisation de mon mot de passe</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||
<h4 class="text-xl font-bold">Reinitialisation de mon mot de passe</h4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="email"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Envoyer un email</Button>
|
||||
</form>
|
||||
<div v-if="status === 'success'" class="border border-light-green dark:border-dark-green bg-light-greenBack dark:bg-dark-greenBack text-wrap mt-4 py-2 px-4 max-w-96">
|
||||
Un mail vous a été envoyé si un compte existe pour cet identifiant.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
usersGoesTo: '/user/profile',
|
||||
});
|
||||
|
||||
const email = ref(''), status = ref<'idle' | 'pending' | 'success' | 'error'>('idle');
|
||||
|
||||
async function submit()
|
||||
{
|
||||
status.value = 'pending';
|
||||
try {
|
||||
await $fetch(`/api/auth/request-reset`, {
|
||||
body: { profile: email.value },
|
||||
method: 'post',
|
||||
});
|
||||
status.value = 'success';
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
status.value = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
87
app/pages/user/(automatic)/resetting-password.vue
Normal file
87
app/pages/user/(automatic)/resetting-password.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Reinitialisation de mon mot de passe</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||
<h4 class="text-center flex-1 text-xl font-bold">Reinitialisation de mon mot de passe</h4>
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ '!border-light-red !dark:border-dark-red': error }"/>
|
||||
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="col-span-2">Prérequis de sécurité</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||
</div>
|
||||
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="newPassword" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/>
|
||||
<Button class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Reinitialiser</Button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
usersGoesTo: '/user/login',
|
||||
});
|
||||
|
||||
const query = useRouter().currentRoute.value.query;
|
||||
|
||||
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false);
|
||||
const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref('');
|
||||
|
||||
const checkedLength = computed(() => newPasswd.value.length >= 8 && newPasswd.value.length <= 128);
|
||||
const checkedLower = computed(() => newPasswd.value.toUpperCase() !== newPasswd.value);
|
||||
const checkedUpper = computed(() => newPasswd.value.toLowerCase() !== newPasswd.value);
|
||||
const checkedDigit = computed(() => /[0-9]/.test(newPasswd.value));
|
||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => newPasswd.value.includes(e)));
|
||||
|
||||
const equalsPasswd = computed(() => newPasswd.value && repeatPasswd.value && newPasswd.value === repeatPasswd.value);
|
||||
|
||||
const error = computed(() => !checkedLength.value || !checkedLower.value || !checkedUpper.value || !checkedDigit.value || !checkedSymbol.value);
|
||||
|
||||
async function submit()
|
||||
{
|
||||
if(!equalsPasswd.value)
|
||||
{
|
||||
manualError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
manualError.value = false;
|
||||
status.value = 'pending';
|
||||
try {
|
||||
const result = await $fetch(`/api/users/${query.i}/reset-password`, {
|
||||
method: 'post',
|
||||
body: {
|
||||
password: newPasswd.value,
|
||||
},
|
||||
query: query,
|
||||
});
|
||||
|
||||
if(result && result.success)
|
||||
{
|
||||
status.value = 'success';
|
||||
|
||||
Toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' });
|
||||
useRouter().push({ name: 'user-login' });
|
||||
}
|
||||
else
|
||||
{
|
||||
throw result.error ?? new Error('Erreur inconnue.');
|
||||
}
|
||||
} catch(e) {
|
||||
status.value = 'error';
|
||||
|
||||
const err = e as any;
|
||||
Toaster.add({ content: err?.data?.message ?? err?.message ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
3
app/pages/user/[id].vue
Normal file
3
app/pages/user/[id].vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
User profile: {{ $route.params.id }}
|
||||
</template>
|
||||
88
app/pages/user/changing-password.vue
Normal file
88
app/pages/user/changing-password.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Modification de mon mot de passe</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||
<h4 class="text-center flex-1 text-xl font-bold">Modification de mon mot de passe</h4>
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/>
|
||||
<TextInput type="password" label="Nouveau mot de passe" name="new-password" autocomplete="new-password" v-model="newPasswd" :class="{ 'border-light-red dark:border-dark-red': error }"/>
|
||||
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="col-span-2">Prérequis de sécurité</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||
</div>
|
||||
<TextInput type="password" label="Repeter le nouveau mot de passe" autocomplete="new-password" v-model="repeatPasswd" :class="{ 'border-light-red dark:border-dark-red': manualError }"/>
|
||||
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Mettre à jour mon mot de passe</Button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
guestsGoesTo: '/user/login',
|
||||
});
|
||||
|
||||
const { user } = useUserSession();
|
||||
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle'), manualError = ref(false);
|
||||
const oldPasswd = ref(''), newPasswd = ref(''), repeatPasswd = ref('');
|
||||
|
||||
const checkedLength = computed(() => newPasswd.value.length >= 8 && newPasswd.value.length <= 128);
|
||||
const checkedLower = computed(() => newPasswd.value.toUpperCase() !== newPasswd.value);
|
||||
const checkedUpper = computed(() => newPasswd.value.toLowerCase() !== newPasswd.value);
|
||||
const checkedDigit = computed(() => /[0-9]/.test(newPasswd.value));
|
||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => newPasswd.value.includes(e)));
|
||||
|
||||
const equalsPasswd = computed(() => newPasswd.value && repeatPasswd.value && newPasswd.value === repeatPasswd.value);
|
||||
|
||||
const error = computed(() => !checkedLength.value || !checkedLower.value || !checkedUpper.value || !checkedDigit.value || !checkedSymbol.value);
|
||||
|
||||
async function submit()
|
||||
{
|
||||
if(!equalsPasswd.value)
|
||||
{
|
||||
manualError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
manualError.value = false;
|
||||
status.value = 'pending';
|
||||
try {
|
||||
const result = await $fetch(`/api/users/${user.value?.id}/change-password`, {
|
||||
method: 'post',
|
||||
body: {
|
||||
oldPassword: oldPasswd.value,
|
||||
newPassword: newPasswd.value,
|
||||
}
|
||||
});
|
||||
|
||||
if(result && result.success)
|
||||
{
|
||||
status.value = 'success';
|
||||
|
||||
Toaster.add({ content: 'Votre mot de passe a été modifié avec succès.', duration: 10000, timer: true, type: 'success' });
|
||||
useRouter().push({ name: 'user-profile' });
|
||||
}
|
||||
else
|
||||
{
|
||||
status.value = 'error';
|
||||
|
||||
Toaster.add({ content: result.error ?? 'Erreur inconnue', duration: 10000, timer: true, type: 'error' });
|
||||
}
|
||||
} catch(e) {
|
||||
status.value = 'error';
|
||||
|
||||
Toaster.add({ content: (e as Error).message ?? e, duration: 10000, timer: true, type: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
93
app/pages/user/login.vue
Normal file
93
app/pages/user/login.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Connexion</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||
<h4 class="text-xl font-bold">Connexion</h4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
|
||||
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/>
|
||||
<TextInput type="password" label="Mot de passe" name="password" autocomplete="current-password" v-model="state.password"/>
|
||||
<Button type="submit" class="border border-light-35 dark:border-dark-35 self-center" :loading="status === 'pending'">Se connecter</Button>
|
||||
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-reset-password' }">Mot de passe oublié ?</NuxtLink>
|
||||
<NuxtLink class="mt-4 text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-register' }">Pas de compte ?</NuxtLink>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ZodError } from 'zod/v4';
|
||||
import { schema, type Login } from '~/schemas/login';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
usersGoesTo: '/user/profile',
|
||||
});
|
||||
|
||||
const state = reactive<Login>({
|
||||
usernameOrEmail: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const { data: result, status, error, refresh } = await useFetch('/api/auth/login', {
|
||||
body: state,
|
||||
immediate: false,
|
||||
method: 'POST',
|
||||
watch: false,
|
||||
ignoreResponseError: true,
|
||||
})
|
||||
|
||||
async function submit()
|
||||
{
|
||||
if(state.usernameOrEmail === "")
|
||||
return Toaster.add({ content: 'Veuillez saisir un nom d\'utilisateur ou un email', timer: true, duration: 10000 });
|
||||
if(state.password === "")
|
||||
return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
|
||||
|
||||
const data = schema.safeParse(state);
|
||||
|
||||
if(data.success)
|
||||
{
|
||||
await refresh();
|
||||
|
||||
const login = result.value;
|
||||
if(!login || !login.success)
|
||||
{
|
||||
handleErrors(login?.error ?? error.value!);
|
||||
}
|
||||
else if(status.value === 'success' && login.success)
|
||||
{
|
||||
Toaster.clear();
|
||||
Toaster.add({ duration: 10000, content: 'Vous êtes maintenant connecté', timer: true, type: 'success' });
|
||||
useRouter().push({ name: 'user-profile' });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
handleErrors(data.error);
|
||||
}
|
||||
}
|
||||
function handleErrors(error: Error | ZodError)
|
||||
{
|
||||
if(!error)
|
||||
return;
|
||||
|
||||
status.value = 'error';
|
||||
|
||||
if(error.hasOwnProperty('issues'))
|
||||
{
|
||||
for(const err of (error as ZodError).issues)
|
||||
{
|
||||
return Toaster.add({ content: err.message, timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return Toaster.add({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
83
app/pages/user/profile.vue
Normal file
83
app/pages/user/profile.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { hasPermissions } from "#shared/auth.util";
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
guestsGoesTo: '/user/login',
|
||||
})
|
||||
const { user, clear } = useUserSession();
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
async function revalidateUser()
|
||||
{
|
||||
loading.value = true;
|
||||
await $fetch(`/api/users/${user.value?.id}/revalidate`, {
|
||||
method: 'post'
|
||||
});
|
||||
loading.value = false;
|
||||
Toaster.add({ closeable: false, duration: 10000, timer: true, content: 'Un mail vous a été envoyé.', type: 'info' });
|
||||
}
|
||||
async function deleteUser()
|
||||
{
|
||||
loading.value = true;
|
||||
await $fetch(`/api/users/${user.value?.id}`, {
|
||||
method: 'delete'
|
||||
});
|
||||
loading.value = false;
|
||||
clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head>
|
||||
<Title>d[any] - Mon profil</Title>
|
||||
</Head>
|
||||
<div class="grid lg:grid-cols-4 grid-col-2 w-full items-start py-8 gap-6 content-start" v-if="user">
|
||||
<div class="flex flex-col gap-4 col-span-4 lg:col-span-3 border border-light-35 dark:border-dark-35 p-4">
|
||||
<div class="flex gap-4">
|
||||
<Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" />
|
||||
<div class="flex flex-col items-start">
|
||||
<h4 class="text-xl font-bold">{{ user.username }}</h4>
|
||||
<h4 class="text-xl font-bold">{{ user.email }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-light-red dark:border-dark-red bg-light-redBack dark:bg-dark-redBack text-light-red dark:text-dark-red py-1 px-3 flex items-center justify-between flex-col md:flex-row"
|
||||
v-if="user.state === 0">
|
||||
<HoverCard>
|
||||
<template v-slot:default>Votre adresse mail n'as pas encore été validée. </template>
|
||||
<template v-slot:content><span>Tant que votre adresse mail n'as pas été validée, vous n'avez que
|
||||
des droits de lecture.</span></template>
|
||||
</HoverCard>
|
||||
<Button class="ms-4" @click="revalidateUser" :loading="loading">Renvoyez un mail</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col self-center flex-1 gap-4">
|
||||
<Button @click="clear">Se deconnecter</Button>
|
||||
<NuxtLink :to="{ name: 'user-changing-password' }" class="flex flex-1"><Button>Modifier mon mot de passe</Button></NuxtLink>
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger asChild><Button :loading="loading"
|
||||
class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer
|
||||
mon compte</Button></AlertDialogTrigger>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
|
||||
<AlertDialogContent
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
|
||||
<AlertDialogTitle class="text-3xl font-light relative -top-2">Suppression de compte
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="text-base pb-2">Supprimer votre compte va supprimer toutes vos
|
||||
données personnelles de notre base de données, incluant vos commentaires et vos
|
||||
contributions. <br />
|
||||
Êtes vous sûr de vouloir supprimer votre compte ?
|
||||
</AlertDialogDescription>
|
||||
<div class="flex flex-1 justify-end gap-4">
|
||||
<AlertDialogCancel asChild><Button>Annuler</Button></AlertDialogCancel>
|
||||
<AlertDialogAction asChild><Button @click="() => deleteUser()" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Supprimer</Button></AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
<NuxtLink v-if="hasPermissions(user.permissions, ['admin'])" :href="{ name: 'admin' }" class="flex" no-prefetch><Button class="flex-1">Administration</Button></NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
119
app/pages/user/register.vue
Normal file
119
app/pages/user/register.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<Head>
|
||||
<Title>d[any] - Inscription</Title>
|
||||
</Head>
|
||||
<div class="flex flex-1 flex-col justify-center items-center">
|
||||
<div class="flex gap-8 items-center">
|
||||
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
|
||||
<h4 class="text-xl font-bold">Inscription</h4>
|
||||
</div>
|
||||
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
|
||||
<TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
|
||||
<TextInput type="email" label="Email" name="email" autocomplete="email" v-model="state.email" class="w-full md:w-auto"/>
|
||||
<TextInput type="password" label="Mot de passe" name="password" autocomplete="new-password" v-model="state.password" class="w-full md:w-auto"/>
|
||||
<div class="grid grid-cols-2 flex-col font-light border border-light-35 dark:border-dark-35 px-4 py-2 m-4 ms-0 text-sm leading-[18px] lg:text-base order-8 col-span-2 md:col-span-1 md:order-none">
|
||||
<span class="col-span-2">Prérequis de sécurité</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLength}"><Icon v-show="!checkedLength" icon="radix-icons:cross-2" />8 à 128 caractères</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedLower}"><Icon v-show="!checkedLower" icon="radix-icons:cross-2" />Une minuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedUpper}"><Icon v-show="!checkedUpper" icon="radix-icons:cross-2" />Une majuscule</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedDigit}"><Icon v-show="!checkedDigit" icon="radix-icons:cross-2" />Un chiffre</span>
|
||||
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
|
||||
</div>
|
||||
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
|
||||
<Label class="pb-2 col-span-2 md:col-span-1 flex flex-row gap-2 items-center"><CheckboxRoot v-model:checked="agreeOnRules" class="border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 w-5 h-5" ><CheckboxIndicator ><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span>J'ai lu et j'accepte les <NuxtLink class="text-accent-blue cursor-pointer" :to="{ name: 'usage' }" target="_blank">conditions d'utilisation</NuxtLink></span></Label>
|
||||
<Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
|
||||
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ZodError } from 'zod/v4';
|
||||
import { schema, type Registration } from '~/schemas/registration';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { Toaster } from '#shared/components.util';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login',
|
||||
usersGoesTo: '/user/profile',
|
||||
});
|
||||
|
||||
const state = reactive<Registration>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const confirmPassword = ref("");
|
||||
|
||||
const checkedLength = computed(() => state.password.length >= 8 && state.password.length <= 128);
|
||||
const checkedLower = computed(() => state.password.toUpperCase() !== state.password);
|
||||
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
|
||||
const checkedDigit = computed(() => /[0-9]/.test(state.password));
|
||||
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
|
||||
const agreeOnRules = ref<boolean>(false);
|
||||
|
||||
const { data: result, status, error, refresh } = await useFetch('/api/auth/register', {
|
||||
body: state,
|
||||
immediate: false,
|
||||
method: 'POST',
|
||||
watch: false,
|
||||
ignoreResponseError: true,
|
||||
});
|
||||
|
||||
async function submit()
|
||||
{
|
||||
if(state.username === '')
|
||||
return Toaster.add({ content: 'Veuillez saisir un nom d\'utilisateur', timer: true, duration: 10000 });
|
||||
if(state.email === '')
|
||||
return Toaster.add({ content: 'Veuillez saisir une adresse mail', timer: true, duration: 10000 });
|
||||
if(state.password === "")
|
||||
return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
|
||||
if(state.password !== confirmPassword.value)
|
||||
return Toaster.add({ content: 'Les deux mots de passe saisis ne correspondent pas', timer: true, duration: 10000 });
|
||||
if(agreeOnRules.value !== true)
|
||||
return Toaster.add({ content: 'Veuillez accepter des conditions d\'utilisations pour vous inscrire', timer: true, duration: 10000 });
|
||||
|
||||
const data = schema.safeParse(state);
|
||||
|
||||
if(data.success)
|
||||
{
|
||||
await refresh();
|
||||
|
||||
const login = result.value;
|
||||
if(!login || !login.success)
|
||||
{
|
||||
handleErrors(login?.error ?? error.value!);
|
||||
}
|
||||
else if(status.value === 'success' && login.success)
|
||||
{
|
||||
Toaster.clear();
|
||||
Toaster.add({ duration: 10000, content: 'Vous avez été enregistré. Pensez à valider votre adresse mail.', timer: true, type: 'success' });
|
||||
await navigateTo('/user/profile');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
handleErrors(data.error);
|
||||
}
|
||||
}
|
||||
function handleErrors(error: Error | ZodError)
|
||||
{
|
||||
if(!error)
|
||||
return;
|
||||
|
||||
status.value = 'error';
|
||||
|
||||
if(error.hasOwnProperty('issues'))
|
||||
{
|
||||
for(const err of (error as ZodError).issues)
|
||||
{
|
||||
return Toaster.add({ content: err.message, timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return Toaster.add({ content: error?.message ?? 'Une erreur est survenue', timer: true, duration: 10000, type: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
7
app/plugins/autofocus.ts
Normal file
7
app/plugins/autofocus.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive('autofocus', {
|
||||
mounted(el, binding) {
|
||||
el.focus();
|
||||
}
|
||||
})
|
||||
})
|
||||
8
app/schemas/login.ts
Normal file
8
app/schemas/login.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const schema = z.object({
|
||||
usernameOrEmail: z.string({ required_error: "Nom d'utilisateur ou email obligatoire" }),
|
||||
password: z.string({ required_error: "Mot de passe obligatoire" }),
|
||||
});
|
||||
|
||||
export type Login = z.infer<typeof schema>;
|
||||
16
app/schemas/project.ts
Normal file
16
app/schemas/project.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { projectFilesTable } from "~/db/schema";
|
||||
|
||||
export const Project = z.array(z.object({
|
||||
id: z.string(),
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
type: z.enum(projectFilesTable.type.enumValues),
|
||||
navigable: z.boolean(),
|
||||
private: z.boolean(),
|
||||
order: z.number().finite(),
|
||||
timestamp: z.string(),
|
||||
}));
|
||||
|
||||
export type ProjectType = z.infer<typeof Project>;
|
||||
export type ProjectItemType = ProjectType[number];
|
||||
56
app/schemas/registration.ts
Normal file
56
app/schemas/registration.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from "zod";
|
||||
|
||||
function securePassword(password: string, ctx: z.RefinementCtx): void {
|
||||
const lowercase = password.toLowerCase();
|
||||
const uppercase = password.toUpperCase();
|
||||
|
||||
if(lowercase === password)
|
||||
{
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Votre mot de passe doit contenir au moins une majuscule",
|
||||
});
|
||||
}
|
||||
if(uppercase === password)
|
||||
{
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Votre mot de passe doit contenir au moins une minuscule",
|
||||
});
|
||||
}
|
||||
if(!/[0-9]/.test(password))
|
||||
{
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Votre mot de passe doit contenir au moins un chiffre",
|
||||
});
|
||||
}
|
||||
if(!" !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => password.includes(e)))
|
||||
{
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Votre mot de passe doit contenir au moins un caractère spécial",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const schema = z.object({
|
||||
username: z.string({ required_error: "Nom d'utilisateur obligatoire" }).min(3, "Votre nom d'utilisateur doit contenir au moins 3 caractères").max(32, "Votre nom d'utilisateur doit contenir au plus 32 caractères").superRefine((user, ctx) => {
|
||||
const test = z.string().email().safeParse(user);
|
||||
if(test.success)
|
||||
{
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.invalid_string,
|
||||
validation: 'email',
|
||||
message: "Votre nom d'utilisateur ne peut pas être une addresse mail",
|
||||
});
|
||||
}
|
||||
}),
|
||||
email: z.string({ required_error: "Email obligatoire" }).email("Adresse mail invalide"),
|
||||
password: z.string({ required_error: "Mot de passe obligatoire" }).min(8, "Votre mot de passe doit contenir au moins 8 caractères").max(128, "Votre mot de passe doit contenir au moins 8 caractères").superRefine(securePassword),
|
||||
data: z.object({
|
||||
|
||||
}).partial().nullish(),
|
||||
});
|
||||
|
||||
export type Registration = z.infer<typeof schema>;
|
||||
86
app/types/api.d.ts
vendored
Normal file
86
app/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { FileType } from '~/types/content';
|
||||
|
||||
export interface SuccessHandler
|
||||
{
|
||||
success: true;
|
||||
session: UserSession;
|
||||
}
|
||||
export interface ErrorHandler
|
||||
{
|
||||
success: false;
|
||||
error: Error | ZodError;
|
||||
}
|
||||
export type Return = SuccessHandler | ErrorHandler;
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: number;
|
||||
home: string;
|
||||
summary: string;
|
||||
}
|
||||
export interface Navigation {
|
||||
title: string;
|
||||
path: string;
|
||||
type: string;
|
||||
private: boolean;
|
||||
children?: Navigation[];
|
||||
}
|
||||
export type FileMetadata = Record<string, boolean | string | number>;
|
||||
export interface File {
|
||||
path: string;
|
||||
owner: number;
|
||||
title: string;
|
||||
order: number;
|
||||
type: FileType;
|
||||
content: string;
|
||||
navigable: boolean;
|
||||
private: boolean;
|
||||
metadata: FileMetadata;
|
||||
}
|
||||
export interface Comment {
|
||||
project: number;
|
||||
path: number;
|
||||
user_id: number;
|
||||
sequence: number;
|
||||
position: number;
|
||||
length: number;
|
||||
content: string;
|
||||
}
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
export interface Tag {
|
||||
tag: string;
|
||||
project: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
||||
export type ProjectSearch = Project &
|
||||
{
|
||||
pages: number;
|
||||
username: string;
|
||||
}
|
||||
export type FileSearch = Omit<File, 'content'> &
|
||||
{
|
||||
comments: number;
|
||||
username: string;
|
||||
}
|
||||
export type CommentSearch = Comment &
|
||||
{
|
||||
username: string;
|
||||
}
|
||||
export type UserSearch = User &
|
||||
{
|
||||
}
|
||||
export type CommentedFile = File &
|
||||
{
|
||||
comments: CommentSearch[];
|
||||
}
|
||||
export interface Search {
|
||||
projects: ProjectSearch[];
|
||||
files: FileSearch[];
|
||||
users: UserSearch[];
|
||||
}
|
||||
77
app/types/auth.d.ts
vendored
Normal file
77
app/types/auth.d.ts
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { SessionConfig } from 'h3'
|
||||
|
||||
import 'vue-router';
|
||||
declare module 'vue-router'
|
||||
{
|
||||
interface RouteMeta
|
||||
{
|
||||
requiresAuth?: boolean;
|
||||
guestsGoesTo?: string;
|
||||
usersGoesTo?: string;
|
||||
rights?: string[];
|
||||
validState?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
import '@nuxt/schema';
|
||||
declare module '@nuxt/schema'
|
||||
{
|
||||
interface RuntimeConfig
|
||||
{
|
||||
session: SessionConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserRawData {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
state: number;
|
||||
}
|
||||
|
||||
export interface UserExtendedData {
|
||||
signin: Date;
|
||||
lastTimestamp: Date;
|
||||
}
|
||||
|
||||
export type Permissions = { permissions: string[] };
|
||||
|
||||
export type User = UserRawData & UserExtendedData & Permissions;
|
||||
|
||||
export interface UserSession {
|
||||
user?: User;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface UserSessionRequired extends UserSession {
|
||||
user: User;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UserSessionComposable {
|
||||
/**
|
||||
* Computed indicating if the auth session is ready
|
||||
*/
|
||||
ready: ComputedRef<boolean>
|
||||
/**
|
||||
* Computed indicating if the user is logged in.
|
||||
*/
|
||||
loggedIn: ComputedRef<boolean>
|
||||
/**
|
||||
* The user object if logged in, null otherwise.
|
||||
*/
|
||||
user: ComputedRef<User | null>
|
||||
/**
|
||||
* The session object.
|
||||
*/
|
||||
session: Ref<UserSession>
|
||||
/**
|
||||
* Fetch the user session from the server.
|
||||
*/
|
||||
fetch: () => Promise<boolean>
|
||||
/**
|
||||
* Clear the user session and remove the session cookie.
|
||||
*/
|
||||
clear: () => Promise<void>
|
||||
}
|
||||
34
app/types/canvas.d.ts
vendored
Normal file
34
app/types/canvas.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface CanvasContent {
|
||||
nodes?: CanvasNode[];
|
||||
edges?: CanvasEdge[];
|
||||
groups?: CanvasGroup[];
|
||||
}
|
||||
export type CanvasColor = {
|
||||
class?: string;
|
||||
} & {
|
||||
hex?: string;
|
||||
};
|
||||
export interface CanvasNode {
|
||||
type: 'group' | 'text';
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color?: CanvasColor;
|
||||
label?: string;
|
||||
text?: string;
|
||||
};
|
||||
export interface CanvasEdge {
|
||||
id: string;
|
||||
fromNode: string;
|
||||
fromSide: 'bottom' | 'top' | 'left' | 'right';
|
||||
toNode: string;
|
||||
toSide: 'bottom' | 'top' | 'left' | 'right';
|
||||
color?: CanvasColor;
|
||||
label?: string;
|
||||
};
|
||||
export interface CanvasGroup {
|
||||
name: string;
|
||||
nodes: string[];
|
||||
}
|
||||
248
app/types/character.d.ts
vendored
Normal file
248
app/types/character.d.ts
vendored
Normal file
@@ -0,0 +1,248 @@
|
||||
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES, DAMAGE_TYPES, WEAPON_TYPES } from "#shared/character.util";
|
||||
import type { Localized } from "../types/general";
|
||||
|
||||
export type MainStat = typeof MAIN_STATS[number];
|
||||
export type Ability = typeof ABILITIES[number];
|
||||
export type Level = typeof LEVELS[number];
|
||||
export type TrainingLevel = typeof TRAINING_LEVELS[number];
|
||||
export type SpellType = typeof SPELL_TYPES[number];
|
||||
export type Category = typeof CATEGORIES[number];
|
||||
export type SpellElement = typeof SPELL_ELEMENTS[number];
|
||||
export type Alignment = typeof ALIGNMENTS[number];
|
||||
export type Resistance = typeof RESISTANCES[number];
|
||||
export type DamageType = typeof DAMAGE_TYPES[number];
|
||||
export type WeaponType = typeof WEAPON_TYPES[number];
|
||||
|
||||
export type FeatureID = string;
|
||||
export type i18nID = string;
|
||||
|
||||
export type RecursiveKeyOf<TObj extends object> = {
|
||||
[TKey in keyof TObj & (string | number)]:
|
||||
TObj[TKey] extends any[] ? `${TKey}` :
|
||||
TObj[TKey] extends object
|
||||
? `${TKey}` | `${TKey}/${RecursiveKeyOf<TObj[TKey]>}`
|
||||
: `${TKey}`;
|
||||
}[keyof TObj & (string | number)];
|
||||
|
||||
export type Character = {
|
||||
id: number;
|
||||
|
||||
name: string; //Free text
|
||||
people?: string; //People ID
|
||||
level: number;
|
||||
aspect?: number;
|
||||
notes?: { public?: string, private?: string }; //Free text
|
||||
|
||||
training: Record<MainStat, Partial<Record<TrainingLevel, number>>>;
|
||||
leveling: Partial<Record<Level, number>>;
|
||||
abilities: Partial<Record<Ability, number>>;
|
||||
variables: CharacterVariables;
|
||||
choices: Record<FeatureID, number[]>;
|
||||
|
||||
owner: number;
|
||||
username?: string;
|
||||
visibility: "private" | "public";
|
||||
};
|
||||
export type CharacterVariables = {
|
||||
health: number;
|
||||
mana: number;
|
||||
exhaustion: number;
|
||||
|
||||
sickness: Array<{ id: string, state: number | true }>;
|
||||
poisons: Array<{ id: string, state: number | true }>;
|
||||
spells: string[]; //Spell ID
|
||||
items: ItemState[];
|
||||
|
||||
money: number;
|
||||
};
|
||||
type ItemState = {
|
||||
id: string;
|
||||
amount: number;
|
||||
enchantments?: string[];
|
||||
charges?: number;
|
||||
equipped?: boolean;
|
||||
state?: any;
|
||||
};
|
||||
export type CharacterConfig = {
|
||||
peoples: Record<string, RaceConfig>;
|
||||
training: Record<MainStat, Record<TrainingLevel, FeatureID[]>>;
|
||||
spells: SpellConfig[];
|
||||
aspects: AspectConfig[];
|
||||
features: Record<FeatureID, Feature>;
|
||||
enchantments: Record<string, EnchantementConfig>; //TODO
|
||||
items: Record<string, ItemConfig>;
|
||||
sickness: Record<string, { id: string, name: string, description: string, effect: FeatureID[] }>;
|
||||
action: Record<string, { id: string, name: string, description: string, cost: number }>;
|
||||
reaction: Record<string, { id: string, name: string, description: string, cost: number }>;
|
||||
freeaction: Record<string, { id: string, name: string, description: string }>;
|
||||
passive: Record<string, { id: string, name: string, description: string }>;
|
||||
texts: Record<i18nID, Localized>;
|
||||
};
|
||||
export type EnchantementConfig = {
|
||||
name: string; //TODO -> TextID
|
||||
effect: Array<FeatureEquipment | FeatureValue | FeatureList>;
|
||||
power: number;
|
||||
}
|
||||
export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig);
|
||||
type CommonItemConfig = {
|
||||
id: string;
|
||||
name: string; //TODO -> TextID
|
||||
description: i18nID;
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
|
||||
weight?: number; //Optionnal but highly recommended
|
||||
price?: number; //Optionnal but highly recommended
|
||||
capacity?: number; //Optionnal as most mundane items should not receive enchantments (potions, herbal heals, etc...)
|
||||
powercost?: number; //Optionnal
|
||||
charge?: number //Max amount of charges
|
||||
enchantments?: string[]; //Enchantment ID
|
||||
effects?: Array<FeatureValue | FeatureEquipment | FeatureList>;
|
||||
equippable: boolean;
|
||||
consummable: boolean;
|
||||
}
|
||||
type ArmorConfig = {
|
||||
category: 'armor';
|
||||
health: number;
|
||||
type: 'light' | 'medium' | 'heavy';
|
||||
absorb: { static: number, percent: number };
|
||||
};
|
||||
type WeaponConfig = {
|
||||
category: 'weapon';
|
||||
type: Array<WeaponType>;
|
||||
damage: {
|
||||
value: string; //Dice formula
|
||||
type: DamageType;
|
||||
};
|
||||
};
|
||||
type WondrousConfig = {
|
||||
category: 'wondrous';
|
||||
};
|
||||
type MundaneConfig = {
|
||||
category: 'mundane';
|
||||
};
|
||||
export type SpellConfig = {
|
||||
id: string;
|
||||
name: string; //TODO -> TextID
|
||||
rank: 1 | 2 | 3 | 4;
|
||||
type: SpellType;
|
||||
cost: number;
|
||||
speed: "action" | "reaction" | number;
|
||||
elements: Array<SpellElement>;
|
||||
description: string; //TODO -> TextID
|
||||
concentration: boolean;
|
||||
range: 'personnal' | number;
|
||||
tags?: string[];
|
||||
};
|
||||
export type RaceConfig = {
|
||||
id: string;
|
||||
name: string; //TODO -> TextID
|
||||
description: string; //TODO -> TextID
|
||||
options: Record<Level, FeatureID[]>;
|
||||
};
|
||||
export type AspectConfig = {
|
||||
name: string;
|
||||
description: string; //TODO -> TextID
|
||||
stat: MainStat | 'special';
|
||||
alignment: Alignment;
|
||||
magic: boolean;
|
||||
difficulty: number;
|
||||
physic: { min: number, max: number };
|
||||
mental: { min: number, max: number };
|
||||
personality: { min: number, max: number };
|
||||
options: FeatureItem[];
|
||||
};
|
||||
|
||||
export type FeatureValue = {
|
||||
id: FeatureID;
|
||||
category: "value";
|
||||
operation: "add" | "set" | "min";
|
||||
property: RecursiveKeyOf<CompiledCharacter> | 'spec' | 'ability' | 'training';
|
||||
value: number | `modifier/${MainStat}` | false;
|
||||
}
|
||||
export type FeatureEquipment = {
|
||||
id: FeatureID;
|
||||
category: "value";
|
||||
operation: "add" | "set" | "min";
|
||||
property: 'weapon/damage/value' | 'armor/health' | 'armor/absorb/flat' | 'armor/absorb/percent';
|
||||
value: number | `modifier/${MainStat}` | false;
|
||||
}
|
||||
export type FeatureList = {
|
||||
id: FeatureID;
|
||||
category: "list";
|
||||
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive";
|
||||
action: "add" | "remove";
|
||||
item: string;
|
||||
};
|
||||
export type FeatureChoice = {
|
||||
id: FeatureID;
|
||||
category: "choice";
|
||||
text: string; //TODO -> TextID
|
||||
settings?: { //If undefined, amount is 1 by default
|
||||
amount: number;
|
||||
exclusive: boolean; //Disallow to pick the same option twice
|
||||
};
|
||||
options: Array<{ text: string, effects: Array<FeatureValue | FeatureList> }>; //TODO -> TextID
|
||||
};
|
||||
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice;
|
||||
export type Feature = {
|
||||
id: FeatureID;
|
||||
description: string; //TODO -> TextID
|
||||
effect: FeatureItem[];
|
||||
};
|
||||
|
||||
export type CompiledCharacter = {
|
||||
id: number;
|
||||
owner?: number;
|
||||
username?: string;
|
||||
name: string;
|
||||
health: number; //Max
|
||||
mana: number; //Max
|
||||
race: string;
|
||||
spellslots: number; //Max
|
||||
artslots: number; //Max
|
||||
spellranks: Record<SpellType, 0 | 1 | 2 | 3>;
|
||||
aspect: string; //ID
|
||||
speed: number | false;
|
||||
capacity: number | false;
|
||||
initiative: number;
|
||||
exhaust: number;
|
||||
itempower: number;
|
||||
|
||||
action: number;
|
||||
reaction: number;
|
||||
|
||||
variables: CharacterVariables,
|
||||
|
||||
defense: {
|
||||
hardcap: number;
|
||||
static: number;
|
||||
activeparry: number;
|
||||
activedodge: number;
|
||||
passiveparry: number;
|
||||
passivedodge: number;
|
||||
};
|
||||
|
||||
mastery: {
|
||||
strength: number;
|
||||
dexterity: number;
|
||||
shield: number;
|
||||
armor: number;
|
||||
multiattack: number;
|
||||
magicpower: number;
|
||||
magicspeed: number;
|
||||
magicelement: number;
|
||||
magicinstinct: number;
|
||||
};
|
||||
|
||||
bonus: {
|
||||
defense: Partial<Record<MainStat, number>>;
|
||||
abilities: Partial<Record<Ability, number>>;
|
||||
}; //Any special bonus goes here
|
||||
resistance: Record<string, number>;
|
||||
|
||||
modifier: Record<MainStat, number>;
|
||||
abilities: Partial<Record<Ability, number>>;
|
||||
level: number;
|
||||
lists: { [K in FeatureList['list']]?: string[] }; //string => ListItem ID
|
||||
|
||||
notes: { public: string, private: string };
|
||||
};
|
||||
0
app/types/content.d.ts
vendored
Normal file
0
app/types/content.d.ts
vendored
Normal file
21
app/types/general.d.ts
vendored
Normal file
21
app/types/general.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
export type Preferences = {
|
||||
markdown: MarkdownPreferences,
|
||||
canvas: CanvasPreferences,
|
||||
} & GeneralPreferences;
|
||||
|
||||
type GeneralPreferences = {
|
||||
|
||||
};
|
||||
type MarkdownPreferences = {
|
||||
editing: 'split' |'reading' | 'editing';
|
||||
};
|
||||
type CanvasPreferences = {
|
||||
gridSnap: boolean;
|
||||
spacing?: number;
|
||||
neighborSnap: boolean;
|
||||
};
|
||||
export type Localized = {
|
||||
fr_FR?: string;
|
||||
en_US?: string;
|
||||
default: string;
|
||||
}
|
||||
17
app/types/map.d.ts
vendored
Normal file
17
app/types/map.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface MapContent
|
||||
{
|
||||
bg: string;
|
||||
interests: MapInterest[];
|
||||
}
|
||||
export interface MapInterest
|
||||
{
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: CanvasColor;
|
||||
valign?: 'start' | 'center' | 'end';
|
||||
halign?: 'start' | 'center' | 'end';
|
||||
}
|
||||
Reference in New Issue
Block a user