Compare commits
1 Commits
main
...
features/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ee412ee0c |
35
.eslintrc.cjs
Normal file
35
.eslintrc.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
'**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
|
||||
'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}',
|
||||
'cypress/support/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
extends: ['plugin:cypress/recommended']
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
},
|
||||
rules: {
|
||||
indent: ['error', 2, { SwitchCase: 1 }],
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
endOfLine: 'auto'
|
||||
}
|
||||
],
|
||||
'vue/multi-word-component-names': 'off'
|
||||
}
|
||||
}
|
||||
44
.gitignore
vendored
44
.gitignore
vendored
@@ -1,28 +1,32 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Misc
|
||||
node_modules
|
||||
.DS_Store
|
||||
.fleet
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
3
.npmrc
3
.npmrc
@@ -1,3 +0,0 @@
|
||||
# https://pnpm.io/npmrc#shamefully-hoist
|
||||
# Fixes: WARN Failed to resolve dependency: @supabase/gotrue-js, present in 'optimizeDeps.include'
|
||||
shamefully-hoist=true
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
"editor.indentSize": "tabSize",
|
||||
"editor.tabSize": 2,
|
||||
}
|
||||
78
README.md
78
README.md
@@ -1,75 +1,5 @@
|
||||
# Nuxt 3 Minimal Starter
|
||||
# Leim Tools
|
||||
A congregate of tools used to visualize TTRPG elements and help content creators (mostly me for now tbh) build their worlds.
|
||||
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm run dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm run build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm run preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
## Timeline
|
||||
Visual helper for time-related data, such as events, eras and characters. Helpful to know how old a character is while something is happening in the world, or how far two events are separated in the timeline.
|
||||
|
||||
57
app.vue
57
app.vue
@@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ConfigProvider } from "radix-vue"
|
||||
|
||||
useHead({
|
||||
titleTemplate: (titleChunk) => {
|
||||
return titleChunk ? `${titleChunk} — TTTools` : "TTTools";
|
||||
},
|
||||
meta: [
|
||||
{ name: "charset", content: "UTF-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1.0" },
|
||||
{ name: "author", content: "Alexis Pelé" },
|
||||
{ name: "generator", content: "Nuxt" },
|
||||
{ name: "msapplication-TileColor", content: "00aba9" },
|
||||
{ name: "theme-color", content: "00aba9" },
|
||||
{ name: "og:type", content: "tabletop-tools" },
|
||||
{ name: "og:url", content: "ttt.alexcreates.fr" },
|
||||
{ name: "robots", content: "noindex, nofollow"}
|
||||
],
|
||||
link: [
|
||||
{ rel: "apple-touch-icon", sizes: "76x76", href: "/apple-touch-icon.png" },
|
||||
{ rel: "icon", sizes: "32x32", type: "image/png", href: "/favicon-32x32.png" },
|
||||
{ rel: "icon", sizes: "16x16", type: "image/png", href: "/favicon-16x16.png" },
|
||||
{ rel: "manifest", href: "/site.webmanifest" },
|
||||
{ rel: "mask-icon", href: "/safari-pinned-tab.svg", color: "#6595b4" },
|
||||
]
|
||||
})
|
||||
|
||||
const useIdFunction = () => useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
<NuxtLoadingIndicator />
|
||||
|
||||
<NuxtLayout>
|
||||
<ConfigProvider :use-id="useIdFunction">
|
||||
<div class="h-full grid grid-cols-[auto_1fr] dark:bg-black transition-colors">
|
||||
<Sidebar />
|
||||
|
||||
<div class="wrapper shadow-body-light dark:shadow-body-dark transition-all">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</NuxtLayout>
|
||||
|
||||
<ClientOnly>
|
||||
<UiToaster />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wrapper > * {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
294
assets/main.css
294
assets/main.css
@@ -1,294 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--primary: 245 58% 51%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--success: 156 72% 67%;
|
||||
--success-muted: 156 72% 90%;
|
||||
--success-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 180 0 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--muted: 244 47% 20%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--popover: 180 0 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--card: 180 0 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217 0 10%;
|
||||
--input: 244 47% 20%;
|
||||
|
||||
--primary: 245 58% 51%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 244 47% 20%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 244 47% 20%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--success: 142 72% 29%;
|
||||
--success-muted: 142 72% 10%;
|
||||
--success-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
.event-red {
|
||||
@apply text-slate-900 bg-red-500 hover:bg-red-600
|
||||
}
|
||||
.event-orange {
|
||||
@apply text-slate-900 bg-orange-500 hover:bg-orange-600
|
||||
}
|
||||
.event-amber {
|
||||
@apply text-slate-900 bg-amber-500 hover:bg-amber-600
|
||||
}
|
||||
.event-yellow {
|
||||
@apply text-slate-900 bg-yellow-600 hover:bg-yellow-700
|
||||
}
|
||||
.event-lime {
|
||||
@apply text-slate-900 bg-lime-500 hover:bg-lime-600
|
||||
}
|
||||
.event-green {
|
||||
@apply text-white bg-green-600 hover:bg-green-700
|
||||
}
|
||||
.event-emerald {
|
||||
@apply text-white bg-emerald-600 hover:bg-emerald-700
|
||||
}
|
||||
.event-teal {
|
||||
@apply text-white bg-teal-600 hover:bg-teal-700
|
||||
}
|
||||
.event-cyan {
|
||||
@apply text-white bg-cyan-600 hover:bg-cyan-700
|
||||
}
|
||||
.event-sky {
|
||||
@apply text-white bg-sky-600 hover:bg-sky-700
|
||||
}
|
||||
.event-blue {
|
||||
@apply text-white bg-blue-600 hover:bg-blue-700
|
||||
}
|
||||
.event-indigo {
|
||||
@apply text-white bg-indigo-600 hover:bg-indigo-700
|
||||
}
|
||||
.event-violet {
|
||||
@apply text-white bg-violet-600 hover:bg-violet-700
|
||||
}
|
||||
.event-purple {
|
||||
@apply text-white bg-purple-600 hover:bg-purple-700
|
||||
}
|
||||
.event-fuchsia {
|
||||
@apply text-white bg-fuchsia-600 hover:bg-fuchsia-700
|
||||
}
|
||||
.event-pink {
|
||||
@apply text-white bg-pink-700 hover:bg-pink-800
|
||||
}
|
||||
.event-rose {
|
||||
@apply text-white bg-rose-700 hover:bg-rose-800
|
||||
}
|
||||
.event-black {
|
||||
@apply text-white bg-stone-500 hover:bg-stone-700
|
||||
}
|
||||
.event-white {
|
||||
@apply text-slate-900 bg-white hover:bg-yellow-200 border-[1px] border-slate-300
|
||||
}
|
||||
|
||||
.dark .event-details-red {
|
||||
--base-color: var(--color-red-800);
|
||||
}
|
||||
.dark .event-details-orange {
|
||||
--base-color: var(--color-orange-800);
|
||||
}
|
||||
.dark .event-details-amber {
|
||||
--base-color: var(--color-amber-800);
|
||||
}
|
||||
.dark .event-details-yellow {
|
||||
--base-color: var(--color-yellow-800);
|
||||
}
|
||||
.dark .event-details-lime {
|
||||
--base-color: var(--color-lime-800);
|
||||
}
|
||||
.dark .event-details-green {
|
||||
--base-color: var(--color-green-800);
|
||||
}
|
||||
.dark .event-details-emerald {
|
||||
--base-color: var(--color-emerald-800);
|
||||
}
|
||||
.dark .event-details-teal {
|
||||
--base-color: var(--color-teal-800);
|
||||
}
|
||||
.dark .event-details-cyan {
|
||||
--base-color: var(--color-cyan-800);
|
||||
}
|
||||
.dark .event-details-sky {
|
||||
--base-color: var(--color-sky-800);
|
||||
}
|
||||
.dark .event-details-blue {
|
||||
--base-color: var(--color-blue-800);
|
||||
}
|
||||
.dark .event-details-indigo {
|
||||
--base-color: var(--color-indigo-800);
|
||||
}
|
||||
.dark .event-details-violet {
|
||||
--base-color: var(--color-violet-800);
|
||||
}
|
||||
.dark .event-details-purple {
|
||||
--base-color: var(--color-purple-800);
|
||||
}
|
||||
.dark .event-details-fuchsia {
|
||||
--base-color: var(--color-fuchsia-800);
|
||||
}
|
||||
.dark .event-details-pink {
|
||||
--base-color: var(--color-pink-800);
|
||||
}
|
||||
.dark .event-details-rose {
|
||||
--base-color: var(--color-rose-800);
|
||||
}
|
||||
.dark .event-details-black {
|
||||
--base-color: var(--color-stone-800);
|
||||
}
|
||||
.dark .event-details-white {
|
||||
--base-color: var(--color-white);
|
||||
}
|
||||
|
||||
.event-details-red {
|
||||
--base-color: var(--color-red-300);
|
||||
}
|
||||
.event-details-orange {
|
||||
--base-color: var(--color-orange-300);
|
||||
}
|
||||
.event-details-amber {
|
||||
--base-color: var(--color-amber-300);
|
||||
}
|
||||
.event-details-yellow {
|
||||
--base-color: var(--color-yellow-300);
|
||||
}
|
||||
.event-details-lime {
|
||||
--base-color: var(--color-lime-300);
|
||||
}
|
||||
.event-details-green {
|
||||
--base-color: var(--color-green-300);
|
||||
}
|
||||
.event-details-emerald {
|
||||
--base-color: var(--color-emerald-300);
|
||||
}
|
||||
.event-details-teal {
|
||||
--base-color: var(--color-teal-300);
|
||||
}
|
||||
.event-details-cyan {
|
||||
--base-color: var(--color-cyan-300);
|
||||
}
|
||||
.event-details-sky {
|
||||
--base-color: var(--color-sky-300);
|
||||
}
|
||||
.event-details-blue {
|
||||
--base-color: var(--color-blue-300);
|
||||
}
|
||||
.event-details-indigo {
|
||||
--base-color: var(--color-indigo-300);
|
||||
}
|
||||
.event-details-violet {
|
||||
--base-color: var(--color-violet-300);
|
||||
}
|
||||
.event-details-purple {
|
||||
--base-color: var(--color-purple-300);
|
||||
}
|
||||
.event-details-fuchsia {
|
||||
--base-color: var(--color-fuchsia-300);
|
||||
}
|
||||
.event-details-pink {
|
||||
--base-color: var(--color-pink-300);
|
||||
}
|
||||
.event-details-rose {
|
||||
--base-color: var(--color-rose-300);
|
||||
}
|
||||
.event-details-black {
|
||||
--base-color: var(--color-stone-300);
|
||||
}
|
||||
.event-details-white {
|
||||
--base-color: var(--color-white);
|
||||
}
|
||||
|
||||
.event-details {
|
||||
--bg-color: color-mix(in srgb, var(--base-color), var(--color-white) 80%);
|
||||
--border-color: color-mix(in srgb, var(--base-color), var(--color-white) 50%);
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
hr {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.dark .event-details {
|
||||
--bg-color: color-mix(in srgb, var(--base-color), var(--color-slate-950) 80%);
|
||||
--border-color: color-mix(in srgb, var(--base-color), var(--color-slate-800) 50%);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all .5s ease;
|
||||
}
|
||||
.fade-delay-enter-active,
|
||||
.fade-delay-leave-active {
|
||||
transition: all .5s ease 1s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to,
|
||||
.fade-delay-enter-from,
|
||||
.fade-delay-leave-to {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -2,14 +2,13 @@
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tsConfigPath": ".nuxt/tsconfig.json",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "assets/main.css",
|
||||
"css": "src/assets/main.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "nuxt",
|
||||
"framework": "vite",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { useCalendar } from "@/stores/CalendarStore"
|
||||
import { computed, type Component, type ComputedRef } from "vue"
|
||||
|
||||
// import { PhMagnifyingGlass } from '@phosphor-icons/vue'
|
||||
import MonthlyLayout from "./state/monthly/Layout.vue"
|
||||
import CenturyLayout from "./state/centennially/Layout.vue"
|
||||
import DecadeLayout from "./state/decennially/Layout.vue"
|
||||
import YearLayout from "./state/yearly/Layout.vue"
|
||||
|
||||
import type { Calendar } from "~/models/CalendarConfig"
|
||||
import type { Category } from "~/models/Category"
|
||||
|
||||
const props = defineProps<{
|
||||
calendarData: Calendar,
|
||||
categories: Category[]
|
||||
}>()
|
||||
|
||||
const { setActiveCalendar } = useCalendar()
|
||||
setActiveCalendar(props.calendarData, props.categories)
|
||||
|
||||
const { currentConfig, jumpToDate, selectedDate } = useCalendar()
|
||||
|
||||
// const { setCharacters } = useCharacters()
|
||||
|
||||
const currentViewComponent: ComputedRef<Component> = computed<Component>(() => {
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
return MonthlyLayout
|
||||
|
||||
case "year":
|
||||
return YearLayout
|
||||
|
||||
case "decade":
|
||||
return DecadeLayout
|
||||
|
||||
case "century":
|
||||
default:
|
||||
return CenturyLayout
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
jumpToDate(selectedDate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full relative">
|
||||
<div class="h-full grid grid-rows-[auto,1fr]">
|
||||
<CalendarMenu />
|
||||
|
||||
<component :is="currentViewComponent" />
|
||||
|
||||
<CalendarSearch />
|
||||
<CalendarFormUpdateEvent />
|
||||
<CalendarFormDeleteEvent />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screen-move, /* apply transition to moving elements */
|
||||
.screen-enter-active,
|
||||
.screen-leave-active {
|
||||
transition: all .4s ease-out;
|
||||
}
|
||||
|
||||
.screen-enter-from,
|
||||
.screen-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ensure leaving items are taken out of layout flow so that moving
|
||||
animations can be calculated correctly. */
|
||||
.screen-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia"
|
||||
import { computed } from "vue"
|
||||
|
||||
import { PhMapPin } from "@phosphor-icons/vue"
|
||||
|
||||
const { defaultDate, getFormattedDateTitle, getRelativeString } = useCalendar()
|
||||
const { selectedDate } = storeToRefs(useCalendar())
|
||||
|
||||
const mainDateTitle = computed(() => getFormattedDateTitle(selectedDate.value, true))
|
||||
// const mainDateTitle = computed(() => convertDateToDays(selectedDate.value))
|
||||
|
||||
const dateDifference = computed(() => getRelativeString(defaultDate, selectedDate.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<h1 class="text-2xl font-bold flex items-center gap-1">
|
||||
<PhMapPin size="26" weight="bold" /> {{ mainDateTitle }}
|
||||
</h1>
|
||||
<h2 class="text-lg italic opacity-75">
|
||||
{{ dateDifference }}
|
||||
</h2>
|
||||
|
||||
<template #fallback>
|
||||
<div class="grid gap-1">
|
||||
<UiSkeleton class="h-8 w-64" />
|
||||
<UiSkeleton class="h-6 w-28" />
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { RPGDate } from "@/models/Date"
|
||||
import type { CalendarEvent } from "~/models/CalendarEvent"
|
||||
|
||||
const props = defineProps<{
|
||||
event: CalendarEvent
|
||||
tileDate: RPGDate
|
||||
}>()
|
||||
|
||||
const { areDatesIdentical, revealEditEventModal, revealDeleteEventModal } = useCalendar()
|
||||
const { lastActiveEvent } = storeToRefs(useCalendar())
|
||||
|
||||
const spansMultipleDays = computed(() => Boolean(props.event.startDate && props.event.endDate))
|
||||
const isStartEvent = computed(() => spansMultipleDays.value && areDatesIdentical(props.tileDate, props.event.startDate))
|
||||
const isEndEvent = computed(() => spansMultipleDays.value && props.event.endDate && areDatesIdentical(props.tileDate, props.event.endDate))
|
||||
|
||||
const titleCharLimit = 50;
|
||||
const eventTitle = computed<string>(() => props.event.title.length <= titleCharLimit ? props.event.title : `${props.event.title.slice(0, titleCharLimit)}…`)
|
||||
|
||||
// Popover code
|
||||
const isPopoverDetailsOpen = ref<boolean>(false)
|
||||
|
||||
function handleDoubleClick() {
|
||||
isPopoverDetailsOpen.value = false
|
||||
lastActiveEvent.value = { ...props.event }
|
||||
revealEditEventModal()
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
isPopoverDetailsOpen.value = false
|
||||
lastActiveEvent.value = { ...props.event }
|
||||
revealDeleteEventModal()
|
||||
}
|
||||
|
||||
function handleClosePopover() {
|
||||
isPopoverDetailsOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Listen for keydown events
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
// If the popover isn't opened, this is not the event we're trying to delete, so return
|
||||
if (!isPopoverDetailsOpen.value) return
|
||||
|
||||
// If the key isn't the delete one, return
|
||||
if (e.key !== "Delete") return
|
||||
|
||||
handleDelete()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopover v-model:open="isPopoverDetailsOpen">
|
||||
<UiPopoverTrigger as-child>
|
||||
<button
|
||||
class="text-xs px-2 py-1 block w-full text-left rounded-sm focus-visible:bg-red-200"
|
||||
:class="
|
||||
cn(
|
||||
event.category ? `event-${event.category.color}` : '',
|
||||
{
|
||||
'italic': event.hidden,
|
||||
'rounded-r-none': isStartEvent,
|
||||
'rounded-l-none': isEndEvent,
|
||||
}
|
||||
)
|
||||
"
|
||||
@dblclick="handleDoubleClick"
|
||||
@keydown.delete="handleDelete"
|
||||
>
|
||||
<div class="line-clamp-2 [overflow-wrap:anywhere] hyphens-auto">
|
||||
{{ eventTitle }}
|
||||
</div>
|
||||
</button>
|
||||
</UiPopoverTrigger>
|
||||
|
||||
<LazyCalendarEventDetails
|
||||
:event
|
||||
:spans-multiple-days
|
||||
:is-start-event
|
||||
:is-end-event
|
||||
@query:close-popover="handleClosePopover"
|
||||
/>
|
||||
</UiPopover>
|
||||
</template>
|
||||
@@ -1,207 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { RPGDate } from "@/models/Date"
|
||||
import type { CalendarEvent } from "@/models/CalendarEvent"
|
||||
import { useCalendar } from "@/stores/CalendarStore"
|
||||
|
||||
import {
|
||||
PhHourglassMedium,
|
||||
PhAlarm,
|
||||
PhHourglassHigh,
|
||||
PhHourglassLow,
|
||||
PhMapPinArea,
|
||||
PhDotsThreeOutlineVertical,
|
||||
PhEye
|
||||
} from "@phosphor-icons/vue"
|
||||
|
||||
const { defaultDate, getFormattedDateTitle, jumpToDate, getRelativeString, revealEditEventModal, revealDeleteEventModal } = useCalendar()
|
||||
const { lastActiveEvent } = storeToRefs(useCalendar())
|
||||
|
||||
const props = defineProps<{
|
||||
event: CalendarEvent
|
||||
spansMultipleDays: boolean
|
||||
isStartEvent?: boolean
|
||||
isEndEvent?: boolean
|
||||
}>()
|
||||
|
||||
// Ref for the popover
|
||||
const eventDetails = ref<HTMLElement>()
|
||||
|
||||
const emit = defineEmits(["query:close-popover"])
|
||||
|
||||
const dateDifference = computed<string>(() => getRelativeString(defaultDate, props.event.startDate))
|
||||
const dateDuration = computed<string | null>(() => props.event.endDate ? getRelativeString(props.event.startDate, props.event.endDate, "compact") : null)
|
||||
|
||||
function handleJumpToDate(date: RPGDate) {
|
||||
jumpToDate(date)
|
||||
emit("query:close-popover")
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit event
|
||||
*/
|
||||
const commandMenuOpened = ref(false)
|
||||
|
||||
function deployEditModal() {
|
||||
lastActiveEvent.value = { ...props.event }
|
||||
revealEditEventModal()
|
||||
commandMenuOpened.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm event deletion
|
||||
*/
|
||||
function deployDeleteModal() {
|
||||
lastActiveEvent.value = { ...props.event }
|
||||
revealDeleteEventModal()
|
||||
commandMenuOpened.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopoverContent
|
||||
class=" w-96 "
|
||||
:align="'center'"
|
||||
:align-offset="50"
|
||||
:side="'left'"
|
||||
:collision-padding="60"
|
||||
:hide-when-detached="true"
|
||||
:class="cn(
|
||||
event.category ? `event-details-${event.category.color}` : '',
|
||||
{
|
||||
'event-details': event.category
|
||||
}
|
||||
)"
|
||||
>
|
||||
<div ref="eventDetails" class="grid gap-1">
|
||||
<header class="pr-12">
|
||||
<div class="mb-2 max-h-24 overflow-y-auto text-lg font-semibold">
|
||||
{{ event.title }}
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<template v-if="!event.endDate">
|
||||
<p class="font-semibold">{{ getFormattedDateTitle(event.startDate, true) }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="font-semibold">
|
||||
{{
|
||||
$t('entity.calendar.date.fromTo',
|
||||
{
|
||||
startDate: getFormattedDateTitle(event.startDate, true),
|
||||
endDate: getFormattedDateTitle(event.endDate, true)
|
||||
}
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mb-1 space-y-1">
|
||||
<template v-if="event.location">
|
||||
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
|
||||
<div>
|
||||
<PhMapPinArea size="16" weight="fill" />
|
||||
</div>
|
||||
<p class="max-h-12 overflow-y-auto">{{ event.location }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
|
||||
<div>
|
||||
<PhAlarm size="16" weight="fill" />
|
||||
</div>
|
||||
<p>{{ dateDifference }}</p>
|
||||
</div>
|
||||
<template v-if="dateDuration">
|
||||
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
|
||||
<div>
|
||||
<PhHourglassMedium size="16" weight="fill" />
|
||||
</div>
|
||||
<p>{{ $t('entity.calendar.date.while', { duration: dateDuration } )}}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="event.category || event.secondaryCategories">
|
||||
<ul class="flex gap-1">
|
||||
<li v-if="event.category">
|
||||
<UiBadge class="mix-blend-luminosity font-bold bg-gray-400 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
|
||||
{{ event.category?.name }}
|
||||
</UiBadge>
|
||||
</li>
|
||||
|
||||
<li v-for="cat in event.secondaryCategories" :key="cat.id">
|
||||
<UiBadge class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
|
||||
{{ cat.name }}
|
||||
</UiBadge>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-if="event.description">
|
||||
<hr class="border-slate-500 mt-2" >
|
||||
|
||||
<div class="max-h-48 overflow-y-auto mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ event.description }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<menu class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
|
||||
<UiPopover v-model:open="commandMenuOpened">
|
||||
<UiPopoverTrigger as-child>
|
||||
<UiButton size="icon" variant="outline" class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600">
|
||||
<PhDotsThreeOutlineVertical size="20" weight="fill" />
|
||||
</UiButton>
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent class="w-fit p-0" align="start" side="right" :collision-padding="20" >
|
||||
<UiCommand>
|
||||
<UiCommandList>
|
||||
<UiCommandGroup>
|
||||
<UiCommandItem value="edit-event" @select="deployEditModal"> {{ $t('ui.action.edit') }} </UiCommandItem>
|
||||
<UiCommandItem value="delete-event" @select="deployDeleteModal"> {{ $t('ui.action.delete') }} </UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</UiCommandList>
|
||||
</UiCommand>
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</menu>
|
||||
|
||||
<nav v-if="event.startDate && event.endDate" class="mt-2 flex gap-2">
|
||||
<UiBadge class="hover:bg-indigo-400 dark:hover:bg-slate-300" as-child>
|
||||
<button
|
||||
class="flex gap-1"
|
||||
title="Naviguer au début"
|
||||
@click="handleJumpToDate(event.startDate!)"
|
||||
>
|
||||
<PhHourglassHigh size="16" weight="fill" /> {{ $t('entity.calendar.event.isStart') }}
|
||||
</button>
|
||||
</UiBadge>
|
||||
<UiBadge class="hover:bg-indigo-400 dark:hover:bg-slate-300" as-child title="Naviguer à la fin">
|
||||
<button
|
||||
class="flex gap-1"
|
||||
title="Naviguer à la fin"
|
||||
@click="handleJumpToDate(event.endDate!)"
|
||||
>
|
||||
<PhHourglassLow size="16" weight="fill" /> {{ $t('entity.calendar.event.isEnd') }}
|
||||
</button>
|
||||
</UiBadge>
|
||||
</nav>
|
||||
|
||||
<UiTooltipProvider v-if="event.hidden" :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiBadge class="absolute -top-2 right-2 flex gap-1">
|
||||
<PhEye size="16" weight="fill" /> {{ $t('entity.calendar.event.isHidden') }}
|
||||
</UiBadge>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>
|
||||
{{ $t('entity.calendar.event.hiddenTooltip') }}
|
||||
</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</UiPopoverContent>
|
||||
</template>
|
||||
@@ -1,200 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, type ComputedRef } from "vue"
|
||||
import { useCalendar } from "@/stores/CalendarStore"
|
||||
|
||||
import {
|
||||
PhCaretDoubleLeft,
|
||||
PhCaretDoubleRight,
|
||||
PhCaretLeft,
|
||||
PhCaretRight
|
||||
} from "@phosphor-icons/vue"
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface DirectionLabels {
|
||||
pastFar: string
|
||||
pastNear: string
|
||||
futureNear: string
|
||||
futureFar: string
|
||||
}
|
||||
|
||||
const { currentConfig, decrementViewMonth, incrementViewMonth, decrementViewYear, incrementViewYear } =
|
||||
useCalendar()
|
||||
|
||||
const activeDirectionLabels: ComputedRef<DirectionLabels> = computed(() => {
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
return {
|
||||
pastFar: t("entity.calendar.years.prevSingular"),
|
||||
pastNear: t("entity.calendar.months.prevSingular"),
|
||||
futureNear: t("entity.calendar.months.nextSingular"),
|
||||
futureFar: t("entity.calendar.years.nextSingular")
|
||||
}
|
||||
|
||||
case "year":
|
||||
return {
|
||||
pastFar: t("entity.calendar.decades.prevSingular"),
|
||||
pastNear: t("entity.calendar.years.prevSingular"),
|
||||
futureNear: t("entity.calendar.years.nextSingular"),
|
||||
futureFar: t("entity.calendar.decades.nextSingular")
|
||||
}
|
||||
|
||||
case "decade":
|
||||
return {
|
||||
pastFar: t("entity.calendar.centuries.prevSingular"),
|
||||
pastNear: t("entity.calendar.decades.prevSingular"),
|
||||
futureNear: t("entity.calendar.decades.nextSingular"),
|
||||
futureFar: t("entity.calendar.centuries.nextSingular")
|
||||
}
|
||||
|
||||
case "century":
|
||||
default:
|
||||
return {
|
||||
pastFar: t("entity.calendar.millenias.prevSingular"),
|
||||
pastNear: t("entity.calendar.centuries.prevSingular"),
|
||||
futureNear: t("entity.calendar.centuries.nextSingular"),
|
||||
futureFar: t("entity.calendar.millenias.nextSingular")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toPastFar(): void {
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
decrementViewYear()
|
||||
break
|
||||
|
||||
case "year":
|
||||
decrementViewYear(10)
|
||||
break
|
||||
|
||||
case "decade":
|
||||
decrementViewYear(100)
|
||||
break
|
||||
|
||||
case "century":
|
||||
default:
|
||||
decrementViewYear(1000)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function toPastNear(): void {
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
decrementViewMonth()
|
||||
break
|
||||
|
||||
case "year":
|
||||
decrementViewYear()
|
||||
break
|
||||
|
||||
case "decade":
|
||||
decrementViewYear(10)
|
||||
break
|
||||
|
||||
case "century":
|
||||
default:
|
||||
decrementViewYear(100)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function toFutureNear(): void {
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
incrementViewMonth()
|
||||
break
|
||||
|
||||
case "year":
|
||||
incrementViewYear()
|
||||
break
|
||||
|
||||
case "decade":
|
||||
incrementViewYear(10)
|
||||
break
|
||||
|
||||
case "century":
|
||||
default:
|
||||
incrementViewYear(100)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function toFutureFar(): void {
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
incrementViewYear()
|
||||
break
|
||||
|
||||
case "year":
|
||||
incrementViewYear(10)
|
||||
break
|
||||
|
||||
case "decade":
|
||||
incrementViewYear(100)
|
||||
break
|
||||
|
||||
case "century":
|
||||
default:
|
||||
incrementViewYear(1000)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton variant="outline" size="icon" @click="toPastFar()">
|
||||
<PhCaretDoubleLeft size="18" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ activeDirectionLabels.pastFar }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton variant="outline" size="icon" @click="toPastNear()">
|
||||
<PhCaretLeft size="18" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ activeDirectionLabels.pastNear }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton variant="outline" size="icon" @click="toFutureNear()">
|
||||
<PhCaretRight size="18" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ activeDirectionLabels.futureNear }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton variant="outline" size="icon" @click="toFutureFar()">
|
||||
<PhCaretDoubleRight size="18" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ activeDirectionLabels.futureFar }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,119 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { type RPGDate } from "@/models/Date"
|
||||
|
||||
import { PhArrowLineLeft, PhArrowLineRight } from "@phosphor-icons/vue"
|
||||
|
||||
const { currentDate, currentConfig, jumpToDate, getRelativeEventFromDate } = useCalendar()
|
||||
|
||||
function handleGotoPreviousEventPage(position: "next" | "prev" = "next") {
|
||||
let fromDate: RPGDate
|
||||
|
||||
// To modify, obviously
|
||||
const daysPerMonth = 32
|
||||
const monthsPerYear = 10
|
||||
|
||||
const toDay = position === "next" ? daysPerMonth : 1
|
||||
const toMonth = position === "next" ? monthsPerYear : 0
|
||||
|
||||
switch (currentConfig.viewType) {
|
||||
case "month":
|
||||
fromDate = {
|
||||
day: toDay,
|
||||
month: currentDate.currentMonth,
|
||||
year: currentDate.currentYear
|
||||
}
|
||||
break
|
||||
|
||||
case "year":
|
||||
fromDate = {
|
||||
day: toDay,
|
||||
month: toMonth,
|
||||
year: currentDate.currentYear
|
||||
}
|
||||
break
|
||||
|
||||
case "decade":
|
||||
fromDate = {
|
||||
day: toDay,
|
||||
month: currentDate.currentMonth,
|
||||
year: currentDate.currentYear
|
||||
}
|
||||
break
|
||||
|
||||
case "century":
|
||||
default:
|
||||
fromDate = {
|
||||
day: toDay,
|
||||
month: currentDate.currentMonth,
|
||||
year: currentDate.currentYear
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const { targetDate } = getRelativeEventFromDate(fromDate, position)
|
||||
|
||||
jumpToDate(targetDate)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<div class="grid items-end w-40 px-4 py-2 border-slate-200 bg-white dark:bg-black dark:border-slate-700 border-x-[1px] border-t-[1px] rounded-t-sm text-sm transition-colors">
|
||||
<ClientOnly>
|
||||
<span>{{ currentDate.currentDateTitle }}</span>
|
||||
|
||||
<template #fallback>
|
||||
<span class="inline-block">
|
||||
<UiSkeleton class="h-[19px] w-full rounded-sm" />
|
||||
</span>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<div>
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="rounded-t-sm rounded-b-none border-b-0"
|
||||
@click="handleGotoPreviousEventPage('prev')"
|
||||
>
|
||||
<PhArrowLineLeft size="22" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>
|
||||
{{ $t('entity.calendar.event.prevPage') }}
|
||||
</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</div>
|
||||
<div>
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="rounded-t-sm rounded-b-none border-b-0"
|
||||
@click="handleGotoPreviousEventPage('next')"
|
||||
>
|
||||
<PhArrowLineRight size="22" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>
|
||||
{{ $t('entity.calendar.event.nextPage') }}
|
||||
</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
|
||||
const { defaultDate, jumpToDefaultDate, getFormattedDateTitle, currentDate } = useCalendar()
|
||||
|
||||
const defaultDateFormatted: string = getFormattedDateTitle(defaultDate, true)
|
||||
|
||||
const buttonDisabledState: ComputedRef<boolean> = computed<boolean>(() => {
|
||||
return currentDate.currentMonth === defaultDate.month && currentDate.currentYear === defaultDate.year
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton size="sm" :disabled="buttonDisabledState" @click="jumpToDefaultDate">
|
||||
{{ $t('entity.calendar.date.today') }}
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ defaultDateFormatted }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useCalendar } from "@/stores/CalendarStore"
|
||||
import { PhCalendarBlank } from "@phosphor-icons/vue"
|
||||
import { computed } from "vue"
|
||||
|
||||
const { currentConfig, viewTypeOptions, getViewTypeTitle, setViewType } = useCalendar()
|
||||
|
||||
const viewTypeTitle = computed(() => getViewTypeTitle(currentConfig.viewType))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton size="sm" variant="secondary">
|
||||
<PhCalendarBlank size="18" weight="fill" />
|
||||
|
||||
{{ viewTypeTitle }}
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent :side="'bottom'" :collision-padding="30">
|
||||
<UiDropdownMenuLabel>
|
||||
{{ $t('ui.displayMode') }}
|
||||
</UiDropdownMenuLabel>
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem
|
||||
v-for="option in viewTypeOptions"
|
||||
:key="option"
|
||||
@click="setViewType(option)"
|
||||
>
|
||||
{{ getViewTypeTitle(option) }}
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhX } from "@phosphor-icons/vue";
|
||||
import type { World } from "~/models/World";
|
||||
|
||||
defineProps<{
|
||||
world: World,
|
||||
modalState?: boolean
|
||||
}>()
|
||||
|
||||
const calendarSkeletonName = ref<string>("")
|
||||
|
||||
function onChangedName(newName: string) {
|
||||
calendarSkeletonName.value = newName
|
||||
}
|
||||
|
||||
const emit = defineEmits(["on-close"])
|
||||
|
||||
function handleClose() {
|
||||
emit("on-close")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiAlertDialog :open="modalState">
|
||||
<UiAlertDialogContent class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl gap-6">
|
||||
<UiAlertDialogTitle>
|
||||
<span class="text-2xl">
|
||||
<strong class="font-bold">{{ world.name }}</strong>
|
||||
<span class="opacity-30"> — </span>
|
||||
<span v-if="calendarSkeletonName">
|
||||
{{ calendarSkeletonName }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('entity.calendar.createDialog.title') }}
|
||||
</span>
|
||||
</span>
|
||||
</UiAlertDialogTitle>
|
||||
|
||||
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClose">
|
||||
<PhX size="20" />
|
||||
</UiButton>
|
||||
|
||||
<CalendarFormCreate @on-changed-name="onChangedName" @on-close="handleClose" />
|
||||
</UiAlertDialogContent>
|
||||
</UiAlertDialog>
|
||||
</template>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCircleNotch } from "@phosphor-icons/vue";
|
||||
import { useToast } from "~/components/ui/toast";
|
||||
import type { Calendar } from "~/models/CalendarConfig";
|
||||
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modalState: boolean,
|
||||
calendar: Calendar | null
|
||||
}>()
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const emit = defineEmits(["on-close"])
|
||||
|
||||
async function handleAction(): Promise<void> {
|
||||
if (isLoading.value) return
|
||||
if (!props.calendar) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await $fetch(`/api/calendars/${props.calendar.id}`, { method: "DELETE" })
|
||||
emit("on-close")
|
||||
|
||||
toast({
|
||||
title: t("entity.calendar.deletedToast.title", { calendar: props.calendar.name }),
|
||||
variant: "success",
|
||||
duration: 3000
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
if (err instanceof Error) {
|
||||
toast({
|
||||
title: err.message,
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the modal from closing if's still loading
|
||||
*
|
||||
* @param e The closing event (can be keydown or click)
|
||||
*/
|
||||
function handleClosing() {
|
||||
if (!isLoading.value) {
|
||||
emit("on-close")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiAlertDialog :open="modalState">
|
||||
<UiAlertDialogContent
|
||||
:disable-outside-pointer-events="true"
|
||||
:trap-focus="true"
|
||||
class="min-w-96"
|
||||
@escape-key-down="handleClosing"
|
||||
@focus-outside="handleClosing"
|
||||
@interact-outside="handleClosing"
|
||||
@pointer-down-outside="handleClosing"
|
||||
>
|
||||
<UiAlertDialogTitle>
|
||||
{{ $t('entity.calendar.deleteDialog.title') }}
|
||||
</UiAlertDialogTitle>
|
||||
|
||||
<UiAlertDialogDescription>
|
||||
{{ $t('entity.calendar.deleteDialog.subtitle') }}
|
||||
</UiAlertDialogDescription>
|
||||
|
||||
<form @submit.prevent="handleAction">
|
||||
<div class="grid grid-cols-2 gap-y-4">
|
||||
<div class="text-red-500 ml-8">
|
||||
<span class="text-sm">
|
||||
<!-- {{ formErrors.message }} -->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="flex gap-2 justify-between">
|
||||
<UiButton type="button" size="sm" variant="outline" @click="handleClosing">
|
||||
{{ $t('ui.action.back') }}
|
||||
</UiButton>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Transition name="fade-delay">
|
||||
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
</Transition>
|
||||
|
||||
<UiButton v-if="calendar" size="sm" variant="destructive" :disabled="isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('entity.deleteOne', { entity: calendar?.name }) }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</UiAlertDialogContent>
|
||||
</UiAlertDialog>
|
||||
</template>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Calendar } from "~/models/CalendarConfig";
|
||||
import { PhAlarm, PhCalendarDots, PhCircleNotch, PhWrench } from "@phosphor-icons/vue";
|
||||
|
||||
const defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: []}
|
||||
const calendarSkeleton = ref<Calendar>({ ...defaultSkeleton })
|
||||
|
||||
onMounted(() => {
|
||||
calendarSkeleton.value = { ...defaultSkeleton }
|
||||
})
|
||||
|
||||
type FormTabs = "global" | "months" | "today"
|
||||
const activeTab = ref<FormTabs>("global")
|
||||
|
||||
/**
|
||||
* === Current date ===
|
||||
*/
|
||||
// If the months data change, just reset today's month
|
||||
// This is a failsafe mainly because of 1) month positions and 2) month names
|
||||
watch(calendarSkeleton.value.months, () => {
|
||||
calendarSkeleton.value.today.month = 0
|
||||
}, { deep: true })
|
||||
|
||||
/**
|
||||
* === Form Validation ===
|
||||
*/
|
||||
|
||||
/** Whether the skeleton has valid month data */
|
||||
const validSkeletonMonths = computed(() => calendarSkeleton.value.months.length > 0)
|
||||
|
||||
/** Whether the skeleton has a valid name */
|
||||
const validSkeletonGeneral = computed(() => calendarSkeleton.value.name)
|
||||
|
||||
/** Whether all the data checks above are a-ok */
|
||||
const validSkeleton = computed(() => validSkeletonGeneral.value && validSkeletonMonths.value)
|
||||
|
||||
/** Send the data to the store for validation */
|
||||
const isCreatingCalendar = ref<boolean>(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
isCreatingCalendar.value = true
|
||||
await $fetch("/api/calendars/create", { method: "POST", body: { ...calendarSkeleton.value, worldId: 1 } })
|
||||
|
||||
emit("on-close")
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
isCreatingCalendar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* === Watch for name changes to display above ===
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(e: "on-changed-name", calendarName: string): void
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(e: "on-close"): void
|
||||
}>()
|
||||
|
||||
/** Hook to emit a debounced event for the changed skeleton name */
|
||||
const handleNameChange = useDebounceFn(() => {
|
||||
emit("on-changed-name", calendarSkeleton.value.name)
|
||||
}, 400)
|
||||
|
||||
function handleFormCancel() {
|
||||
emit("on-close")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="calendarSkeleton">
|
||||
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
|
||||
<UiTabs v-model:model-value="activeTab">
|
||||
<UiTabsList class="grid w-full grid-cols-3 mb-4">
|
||||
<UiTabsTrigger value="global" class="font-bold">
|
||||
<div class="flex items-center gap-1">
|
||||
<PhWrench size="18" weight="fill" />
|
||||
|
||||
{{ $t('entity.calendar.createDialog.tabs.general.title') }}
|
||||
</div>
|
||||
</UiTabsTrigger>
|
||||
<UiTabsTrigger value="months" class="font-bold">
|
||||
<div class="flex items-center gap-1">
|
||||
<PhCalendarDots size="18" weight="fill" />
|
||||
|
||||
{{ $t('entity.calendar.createDialog.tabs.months.title') }}
|
||||
</div>
|
||||
</UiTabsTrigger>
|
||||
<UiTabsTrigger value="today" class="font-bold">
|
||||
<div class="flex items-center gap-1">
|
||||
<PhAlarm size="18" weight="fill" />
|
||||
|
||||
{{ $t('entity.calendar.createDialog.tabs.today.title') }}
|
||||
</div>
|
||||
</UiTabsTrigger>
|
||||
</UiTabsList>
|
||||
<UiTabsContent value="global">
|
||||
<input
|
||||
id="new-calendar-name"
|
||||
v-model="calendarSkeleton.name"
|
||||
type="text"
|
||||
name="new-calendar-name"
|
||||
required
|
||||
:placeholder="$t('common.title')"
|
||||
class="w-full -my-1 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
|
||||
@input="handleNameChange"
|
||||
>
|
||||
</UiTabsContent>
|
||||
<UiTabsContent value="months">
|
||||
<CalendarInputMonthList v-model:model-value="calendarSkeleton.months" />
|
||||
</UiTabsContent>
|
||||
<UiTabsContent value="today">
|
||||
<CalendarInputTodaySelect v-model:model-value="calendarSkeleton.today" :available-months="calendarSkeleton.months"/>
|
||||
</UiTabsContent>
|
||||
</UiTabs>
|
||||
|
||||
<footer class="flex justify-end gap-2 mt-6">
|
||||
<UiButton type="button" variant="destructive" @click="handleFormCancel">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
|
||||
<UiButton type="submit" :disabled="!validSkeleton || isCreatingCalendar">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isCreatingCalendar" size="20" class="opacity-50 animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('ui.action.save') }}
|
||||
</UiButton>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,240 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RPGDate } from "~/models/Date";
|
||||
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhTag } from "@phosphor-icons/vue"
|
||||
|
||||
const { eventSkeleton, operationInProgress } = storeToRefs(useCalendar())
|
||||
const { resetSkeleton, submitSkeleton, cancelLatestRequest } = useCalendar()
|
||||
const popoverOpen = ref(false)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formErrors = reactive<{ message: string | null }>({
|
||||
message: null
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
date?: RPGDate
|
||||
btnClass?: string
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Opens event creation's popover
|
||||
*/
|
||||
function openEventCreatePopover() {
|
||||
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
|
||||
if (operationInProgress.value) {
|
||||
popoverOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
resetSkeleton()
|
||||
|
||||
popoverOpen.value = true
|
||||
|
||||
// Set skeleton initial startDate if it's known
|
||||
if (props.date) {
|
||||
eventSkeleton.value.startDate = { ...props.date } // We need to clone it otherwise the props ends up mutating (?)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// Prevent form submission if already loading
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await submitSkeleton()
|
||||
|
||||
popoverOpen.value = false
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
formErrors.message = err.message
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the modal from closing if's still loading
|
||||
*
|
||||
* @param e The closing event (can be keydown or click)
|
||||
*/
|
||||
function handleClosing(e: Event) {
|
||||
if (isLoading.value) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the cancel button
|
||||
*
|
||||
* Must cancel the abortController in the store, and stop the loading
|
||||
*/
|
||||
function handleCancel() {
|
||||
cancelLatestRequest()
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopover v-model:open="popoverOpen">
|
||||
<UiPopoverTrigger as-child>
|
||||
<button :class="btnClass" @click="openEventCreatePopover()" />
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent
|
||||
:align="'center'"
|
||||
:side="'right'"
|
||||
:collision-padding="60"
|
||||
:disable-outside-pointer-events="true"
|
||||
:trap-focus="true"
|
||||
class="pl-3 w-[30rem] max-w-full border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
|
||||
@escape-key-down="handleClosing"
|
||||
@focus-outside="handleClosing"
|
||||
@interact-outside="handleClosing"
|
||||
@pointer-down-outside="handleClosing"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-2 gap-y-3">
|
||||
<div class="col-span-2 pl-8">
|
||||
<input
|
||||
id="new-event-title"
|
||||
v-model="eventSkeleton.title"
|
||||
type="text"
|
||||
name="new-event-title"
|
||||
required
|
||||
:placeholder="$t('entity.calendar.event.title')"
|
||||
:maxlength="120"
|
||||
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,120}"
|
||||
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
|
||||
>
|
||||
|
||||
<div class="mt-2 mb-1 text-xs opacity-50">
|
||||
{{ t('entity.calendar.event.patterns.title') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 my-2 pl-8">
|
||||
<textarea
|
||||
id="new-event-description"
|
||||
v-model="eventSkeleton.description"
|
||||
name="new-event-description"
|
||||
:placeholder="$t('entity.addDescription')"
|
||||
:maxlength="1200"
|
||||
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
|
||||
/>
|
||||
<div class="mt-2 mb-1 text-xs opacity-50">
|
||||
{{ t('entity.calendar.event.patterns.description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhAlarm size="18" weight="fill" />
|
||||
|
||||
<CalendarInputRPGDate
|
||||
v-model:model-value="eventSkeleton.startDate"
|
||||
:placeholder="$t('entity.calendar.date.start')"
|
||||
:initial-date="props.date"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<span>—</span>
|
||||
|
||||
<CalendarInputRPGDate
|
||||
v-model:model-value="eventSkeleton.endDate"
|
||||
:placeholder="$t('entity.calendar.date.end')"
|
||||
:initial-date="props.date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhTag size="18" weight="fill" />
|
||||
|
||||
<CalendarInputEventCategory
|
||||
v-model="eventSkeleton.category"
|
||||
:placeholder="$t('entity.category.addPrimary')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhTag size="18" weight="fill" />
|
||||
|
||||
<CalendarInputEventCategories v-model="eventSkeleton.secondaryCategories" :placeholder="$t('entity.category.addSecondaries')" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="col-span-2 mb-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhMapPinArea size="18" weight="fill" />
|
||||
|
||||
<div class="grow">
|
||||
<input
|
||||
id="new-event-location"
|
||||
v-model="eventSkeleton.location"
|
||||
type="text"
|
||||
name="new-event-location"
|
||||
:placeholder="$t('entity.calendar.event.addLocation')"
|
||||
:maxlength="160"
|
||||
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
|
||||
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
|
||||
>
|
||||
<div class="mt-2 mb-1 text-xs opacity-50">
|
||||
{{ t('entity.calendar.event.patterns.location') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
|
||||
<PhEyeClosed v-else size="18" />
|
||||
|
||||
<div class="flex items-center gap-x-2">
|
||||
<UiSwitch id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
|
||||
<UiLabel for="new-event-visibility">
|
||||
<template v-if="!eventSkeleton.hidden">
|
||||
{{ $t('entity.calendar.event.isPublic') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('entity.calendar.event.isHidden') }}
|
||||
</template>
|
||||
</UiLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 pl-8">
|
||||
<span class="text-sm">
|
||||
{{ formErrors.message }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Transition name="fade-delay">
|
||||
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
</Transition>
|
||||
|
||||
<UiButton size="sm" :disabled="isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('ui.action.save') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</template>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCircleNotch } from "@phosphor-icons/vue";
|
||||
import { useToast } from "~/components/ui/toast";
|
||||
|
||||
const { resetSkeleton, deleteEventFromSkeleton, cancelLatestRequest } = useCalendar()
|
||||
const { isDeleteEventModalOpen, eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
|
||||
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const formErrors = reactive<{ message: string | null }>({
|
||||
message: null
|
||||
})
|
||||
|
||||
// Watch the popover state
|
||||
watch(isDeleteEventModalOpen, (hasOpened, _o) => {
|
||||
if (hasOpened && lastActiveEvent.value) {
|
||||
eventSkeleton.value = { ...lastActiveEvent.value }
|
||||
}
|
||||
})
|
||||
|
||||
async function handleAction(): Promise<void> {
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const eventTitle = eventSkeleton.value.title
|
||||
|
||||
try {
|
||||
await deleteEventFromSkeleton()
|
||||
|
||||
isDeleteEventModalOpen.value = false
|
||||
resetSkeleton()
|
||||
|
||||
toast({
|
||||
title: t("entity.calendar.event.deletedToast.title", { event: eventTitle }),
|
||||
variant: "success",
|
||||
duration: 3000
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
formErrors.message = err.message
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the modal from closing if's still loading
|
||||
*
|
||||
* @param e The closing event (can be keydown or click)
|
||||
*/
|
||||
function handleClosing(e: Event): void {
|
||||
if (isLoading.value) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the cancel button
|
||||
*
|
||||
* Must cancel the abortController in the store, and stop the loading
|
||||
*/
|
||||
function handleCancel(): void {
|
||||
cancelLatestRequest()
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiAlertDialog v-model:open="isDeleteEventModalOpen">
|
||||
<UiAlertDialogContent
|
||||
:disable-outside-pointer-events="true"
|
||||
:trap-focus="true"
|
||||
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
|
||||
@escape-key-down="handleClosing"
|
||||
@focus-outside="handleClosing"
|
||||
@interact-outside="handleClosing"
|
||||
@pointer-down-outside="handleClosing"
|
||||
>
|
||||
<UiAlertDialogTitle>{{ $t('entity.calendar.event.deleteDialog.title') }}</UiAlertDialogTitle>
|
||||
|
||||
<UiAlertDialogDescription>
|
||||
{{ $t('entity.calendar.event.deleteDialog.subtitle') }}
|
||||
</UiAlertDialogDescription>
|
||||
|
||||
<form @submit.prevent="handleAction">
|
||||
<div class="grid grid-cols-2 gap-y-4">
|
||||
<div class="text-red-500 ml-8">
|
||||
<span class="text-sm">
|
||||
{{ formErrors.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="flex gap-2 justify-between">
|
||||
<UiButton type="button" size="sm" variant="outline" @click="() => isDeleteEventModalOpen = false">
|
||||
{{ $t('ui.action.back') }}
|
||||
</UiButton>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Transition name="fade-delay">
|
||||
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
</Transition>
|
||||
|
||||
<UiButton size="sm" variant="destructive" :disabled="isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('ui.action.delete') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</UiAlertDialogContent>
|
||||
</UiAlertDialog>
|
||||
</template>
|
||||
@@ -1,244 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhPencilSimpleLine, PhTag } from "@phosphor-icons/vue"
|
||||
import { useToast } from "~/components/ui/toast";
|
||||
import type { APIError } from "~/models/Errors";
|
||||
|
||||
const { resetSkeleton, updateEventFromSkeleton, cancelLatestRequest } = useCalendar()
|
||||
const { eventSkeleton, lastActiveEvent, isEditEventModalOpen } = storeToRefs(useCalendar())
|
||||
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formErrors = reactive<{ message: string | null }>({
|
||||
message: null
|
||||
})
|
||||
|
||||
// Watch the popover state
|
||||
watch(isEditEventModalOpen, (hasOpened, _o) => {
|
||||
if (hasOpened && lastActiveEvent.value) {
|
||||
eventSkeleton.value = { ...lastActiveEvent.value }
|
||||
}
|
||||
})
|
||||
|
||||
async function handleAction() {
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await updateEventFromSkeleton()
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiError = (err as any).data as APIError
|
||||
|
||||
apiError.data.errors.forEach((error) => {
|
||||
toast({
|
||||
title: t("entity.calendar.event.editErrors.toastTitle"),
|
||||
variant: "destructive",
|
||||
description: t(`entity.calendar.event.editErrors.${error.path[1]}_${error.code}`),
|
||||
duration: 2000,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
resetSkeleton()
|
||||
isLoading.value = false
|
||||
isEditEventModalOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the modal from closing if's still loading
|
||||
*
|
||||
* @param e The closing event (can be keydown or click)
|
||||
*/
|
||||
function handleClosing(e: Event) {
|
||||
if (isLoading.value) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the cancel button
|
||||
*
|
||||
* Must cancel the abortController in the store, and stop the loading
|
||||
*/
|
||||
function handleCancel() {
|
||||
cancelLatestRequest()
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDialog v-model:open="isEditEventModalOpen">
|
||||
<UiDialogContent
|
||||
:disable-outside-pointer-events="true"
|
||||
:trap-focus="true"
|
||||
class="pl-3 min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
|
||||
@escape-key-down="handleClosing"
|
||||
@focus-outside="handleClosing"
|
||||
@interact-outside="handleClosing"
|
||||
@pointer-down-outside="(e) => e.preventDefault()"
|
||||
>
|
||||
<header class="pl-8 grid gap-y-2">
|
||||
<UiDialogTitle>
|
||||
{{ $t('entity.calendar.event.editDialog.title') }}
|
||||
</UiDialogTitle>
|
||||
|
||||
<UiDialogDescription>
|
||||
{{ $t('entity.calendar.event.editDialog.subtitle') }}
|
||||
</UiDialogDescription>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="handleAction">
|
||||
<div class="grid grid-cols-2 gap-y-3">
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhPencilSimpleLine size="20" weight="fill" />
|
||||
|
||||
<div class="grow">
|
||||
<input
|
||||
id="new-event-title"
|
||||
v-model="eventSkeleton.title"
|
||||
type="text"
|
||||
name="new-event-title"
|
||||
required
|
||||
:placeholder="$t('entity.calendar.event.title')"
|
||||
:maxlength="120"
|
||||
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,120}"
|
||||
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
|
||||
>
|
||||
<div class="mt-2 mb-1 text-xs opacity-50">
|
||||
{{ t('entity.calendar.event.patterns.title') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 my-2 ml-8">
|
||||
<textarea
|
||||
id="new-event-description"
|
||||
v-model="eventSkeleton.description"
|
||||
name="new-event-description"
|
||||
:placeholder="$t('entity.addDescription')"
|
||||
:maxlength="1200"
|
||||
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
|
||||
/>
|
||||
<div class="mt-2 mb-1 text-xs opacity-50">
|
||||
{{ t('entity.calendar.event.patterns.description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhAlarm size="18" weight="fill" />
|
||||
|
||||
<CalendarInputRPGDate
|
||||
v-model:model-value="eventSkeleton.startDate"
|
||||
:placeholder="$t('entity.calendar.date.start')"
|
||||
:initial-date="lastActiveEvent?.startDate"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<span>—</span>
|
||||
|
||||
<CalendarInputRPGDate
|
||||
v-model:model-value="eventSkeleton.endDate"
|
||||
:placeholder="$t('entity.calendar.date.end')"
|
||||
:initial-date="lastActiveEvent?.endDate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhTag size="18" weight="fill" />
|
||||
|
||||
<div class="w-1/2">
|
||||
<CalendarInputEventCategory
|
||||
v-model="eventSkeleton.category"
|
||||
:placeholder="$t('entity.category.addPrimary')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhTag size="18" weight="fill" />
|
||||
|
||||
<div class="w-1/2">
|
||||
<CalendarInputEventCategories v-model="eventSkeleton.secondaryCategories" placeholder="Ajouter des catégories secondaires" />
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="col-span-2 mb-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhMapPinArea size="18" weight="fill" />
|
||||
|
||||
<div class="grow">
|
||||
<input
|
||||
id="new-event-location"
|
||||
v-model="eventSkeleton.location"
|
||||
type="text"
|
||||
name="new-event-location"
|
||||
:placeholder="$t('entity.calendar.event.addLocation')"
|
||||
:maxlength="160"
|
||||
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
|
||||
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600 invalid:border-red-500"
|
||||
>
|
||||
|
||||
<div class="mt-2 mb-1 text-xs opacity-50">
|
||||
{{ t('entity.calendar.event.patterns.location') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
|
||||
<PhEyeClosed v-else size="18" />
|
||||
|
||||
<div class="flex items-center gap-x-2">
|
||||
<UiSwitch id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
|
||||
<UiLabel for="new-event-visibility">
|
||||
<template v-if="!eventSkeleton.hidden">
|
||||
{{ $t('entity.calendar.event.isPublic') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('entity.calendar.event.isHidden') }}
|
||||
</template>
|
||||
</UiLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 ml-8">
|
||||
<span class="text-sm">
|
||||
{{ formErrors.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="flex gap-2 justify-end">
|
||||
<Transition name="fade-delay">
|
||||
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
</Transition>
|
||||
|
||||
<UiButton size="sm" :disabled="isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('ui.action.save') }}
|
||||
</UiButton>
|
||||
</footer>
|
||||
</form>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Category } from "~/models/Category";
|
||||
|
||||
import { PhCaretDown, PhCheck } from "@phosphor-icons/vue";
|
||||
|
||||
const isPopoverOpen = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<Category[]>({ default: [] })
|
||||
const modelBuffer = ref<Category[]>([])
|
||||
|
||||
watch(modelBuffer.value, () => {
|
||||
model.value = [ ...modelBuffer.value ]
|
||||
})
|
||||
|
||||
const { categories: availableCategories } = useCalendar()
|
||||
|
||||
const searchTerm = ref<string>("")
|
||||
|
||||
const filteredCategories = computed(() =>
|
||||
searchTerm.value === ""
|
||||
? availableCategories
|
||||
: availableCategories.filter((category) => {
|
||||
return category.name.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopover v-model:open="isPopoverOpen">
|
||||
<UiPopoverTrigger as-child>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
class="relative w-full max-w-full h-fit justify-between"
|
||||
>
|
||||
<template v-if="!model.length">
|
||||
{{ props.placeholder }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<ul class="flex flex-wrap gap-1">
|
||||
<li v-for="category in model" :key="`selected-cat-${category.id}`">
|
||||
<UiBadge class="lowercase" variant="secondary">
|
||||
{{ category.name }}
|
||||
</UiBadge>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<PhCaretDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</UiButton>
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
:collision-padding="50"
|
||||
class="w-fit h-[33vh] p-0"
|
||||
>
|
||||
<UiCommand v-model="modelBuffer" v-model:searchTerm="searchTerm" :multiple="true">
|
||||
<UiCommandInput :placeholder="$t('entity.category.search')" />
|
||||
<UiCommandEmpty>{{ $t('entity.category.notFoundAny') }}</UiCommandEmpty>
|
||||
<UiCommandList>
|
||||
<UiCommandGroup>
|
||||
<UiCommandItem
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
:value="category"
|
||||
class="cursor-pointer flex justify-between items-center"
|
||||
>
|
||||
<span>
|
||||
{{ category.name }}
|
||||
</span>
|
||||
|
||||
<PhCheck v-if="model.find(cat => cat.id === category.id)" />
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</UiCommandList>
|
||||
</UiCommand>
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</template>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCaretDown } from "@phosphor-icons/vue";
|
||||
import type { Category } from "~/models/Category";
|
||||
|
||||
const isPopoverOpen = ref<boolean>(false)
|
||||
|
||||
const props = defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<Category>()
|
||||
|
||||
const { categories: availableCategories } = useCalendar()
|
||||
|
||||
const searchTerm = ref<string>("")
|
||||
|
||||
function handleCatSelect() {
|
||||
isPopoverOpen.value = false
|
||||
}
|
||||
|
||||
const filteredCategories = computed(() =>
|
||||
searchTerm.value === ""
|
||||
? availableCategories
|
||||
: availableCategories.filter((category) => {
|
||||
return category.name.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopover v-model:open="isPopoverOpen">
|
||||
<UiPopoverTrigger as-child>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
class="w-full max-w-full justify-between"
|
||||
>
|
||||
<template v-if="!model">
|
||||
{{ props.placeholder }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ model.name }}
|
||||
</template>
|
||||
|
||||
<PhCaretDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</UiButton>
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
:collision-padding="50"
|
||||
class="w-fit h-[33vh] p-0"
|
||||
>
|
||||
<UiCommand v-model="model" v-model:searchTerm="searchTerm">
|
||||
<UiCommandInput :placeholder="$t('entity.category.search')" />
|
||||
<UiCommandEmpty>{{ $t('entity.category.notFoundAny') }}</UiCommandEmpty>
|
||||
<UiCommandList>
|
||||
<UiCommandGroup>
|
||||
<UiCommandItem
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
:value="category"
|
||||
class="cursor-pointer"
|
||||
@select="handleCatSelect"
|
||||
>
|
||||
{{ category.name }}
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</UiCommandList>
|
||||
</UiCommand>
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</template>
|
||||
@@ -1,172 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { useSortable } from "@vueuse/integrations/useSortable";
|
||||
import type { CalendarMonth } from "~/models/CalendarMonth";
|
||||
|
||||
import { PhList, PhPlus, PhTrash } from "@phosphor-icons/vue";
|
||||
|
||||
const model = defineModel<CalendarMonth[]>({ required: true })
|
||||
|
||||
/**
|
||||
* Input value for new month's name
|
||||
*/
|
||||
const monthName: Ref<string | undefined> = ref<string>()
|
||||
const monthNameRef = ref<HTMLInputElement>()
|
||||
const { focused: monthNameFocused } = useFocus(monthNameRef)
|
||||
const validMonthNameDatatypes = ["string"]
|
||||
const monthNameIsTaken = computed(() => model.value.find(m => m.name === monthName.value))
|
||||
const validMonthName = computed(() => validMonthNameDatatypes.includes(typeof monthName.value) && !monthNameIsTaken.value)
|
||||
|
||||
/**
|
||||
* Input value for new month's number of days
|
||||
*/
|
||||
const monthDays: Ref<number | undefined> = ref<number>()
|
||||
const monthDaysRef = ref<HTMLInputElement>()
|
||||
const validMonthDaysDatatypes = ["number"]
|
||||
const validMonthDays = computed(() => validMonthDaysDatatypes.includes(typeof monthDays.value) && monthDays.value && monthDays.value >= 12)
|
||||
|
||||
const validNewMonth = computed(() => validMonthDays.value && validMonthName.value)
|
||||
|
||||
/**
|
||||
* Add current month input data to the model list
|
||||
*/
|
||||
function addMonthToModel(): void {
|
||||
if (!monthDays.value || !monthName.value) return
|
||||
|
||||
// Create month object to add
|
||||
const monthToInsert: CalendarMonth = {
|
||||
name: monthName.value,
|
||||
days: monthDays.value,
|
||||
position: model.value.length
|
||||
}
|
||||
|
||||
model.value.push(monthToInsert)
|
||||
|
||||
// Reset form state
|
||||
monthName.value = ""
|
||||
monthNameFocused.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific month from the model
|
||||
*
|
||||
* @param index Index position of the month in the model
|
||||
*/
|
||||
function removeMonthFromModel(index: number) {
|
||||
if (isNaN(index)) return
|
||||
|
||||
model.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Sortable setup
|
||||
const monthSortableList = ref<HTMLElement | null>(null)
|
||||
useSortable(monthSortableList, model.value, { animation: 150, handle: ".handle" })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid md:grid-cols-12 gap-4 items-center">
|
||||
<div class="md:col-start-2 md:col-span-5">
|
||||
<UiInput
|
||||
id="new-month-name"
|
||||
ref="monthNameRef"
|
||||
v-model="monthName"
|
||||
type="text"
|
||||
name="newMonthName"
|
||||
:placeholder="$t('entity.calendar.months.inputName')"
|
||||
:class="cn({ 'border-red-600': monthNameIsTaken })"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-5">
|
||||
<UiInput
|
||||
id="new-month-days"
|
||||
ref="monthDaysRef"
|
||||
v-model="monthDays"
|
||||
type="number"
|
||||
name="newMonthName"
|
||||
:placeholder="$t('entity.calendar.months.daysNb')"
|
||||
min="0"
|
||||
step="1"
|
||||
class="invalid:border-red-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-1">
|
||||
<UiButton size="icon" class="rounded-full h-8 w-8" :disabled="!validNewMonth" @click.prevent="addMonthToModel">
|
||||
<PhPlus size="17"/>
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="md:col-span-full">
|
||||
<div
|
||||
class="border-[1px] dark:border-slate-800 p-4 rounded-sm max-h-80 overflow-y-auto"
|
||||
:class="model.length ? 'md:grid md:grid-cols-12 md:gap-4 md:items-center' : ''"
|
||||
>
|
||||
<div v-if="model.length" class="hidden md:block col-span-1">
|
||||
<ul class="grid gap-y-4 justify-center">
|
||||
<li v-for="(m, i) in model" :key="`num-${m.name}`">
|
||||
<UiButton size="icon" variant="secondary" class="h-8 w-8 rounded-full">
|
||||
<span class="font-bold text-sm">{{ i + 1 }}</span>
|
||||
</UiButton>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-11">
|
||||
<ul ref="monthSortableList" class="grid gap-y-2" :class="model.length ? 'visible' : 'absolute invisible'">
|
||||
<template v-if="model.length">
|
||||
<li v-for="(m, i) in model" :key="m.name" class="grid md:grid-cols-12 gap-4 md:items-center text-slate-900 bg-slate-200 rounded-md">
|
||||
<div class="md:col-span-1 text-right duration-200 ease-out transition transform origin-top-right">
|
||||
<UiButton type="button" variant="ghost" size="icon" class="handle rounded-full h-8 w-8">
|
||||
<PhList size="17" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="md:col-span-3">
|
||||
<div class="font-bold md:pl-2">
|
||||
{{ m.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<UiInput
|
||||
:id="`month-days-n${i}`"
|
||||
v-model="m.days"
|
||||
class="bg-transparent border-none"
|
||||
type="number"
|
||||
:name="`monthDays-n${i}`"
|
||||
:placeholder="$t('entity.calendar.months.daysNb')"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-1">
|
||||
{{ $t('entity.calendar.months.daysMaybePlural') }}
|
||||
</div>
|
||||
|
||||
<div class="md:col-start-12">
|
||||
<UiTooltipProvider>
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton type="button" variant="ghost" size="icon" class="rounded-full h-8 w-8" @click="removeMonthFromModel(i)">
|
||||
<PhTrash size="17" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>
|
||||
{{ $t('entity.calendar.months.deleteOne', { month: m.name }) }}
|
||||
</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
<template v-if="!model.length">
|
||||
<p class="col-span-12 text-lg text-center italic opacity-50">
|
||||
{{ $t('entity.calendar.months.none') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,169 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RPGDate } from "~/models/Date";
|
||||
|
||||
import {
|
||||
PhXCircle
|
||||
} from "@phosphor-icons/vue"
|
||||
|
||||
const model = defineModel<RPGDate | null>()
|
||||
|
||||
const id = useId()
|
||||
|
||||
const props = defineProps<{
|
||||
initialDate?: RPGDate
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
}>()
|
||||
|
||||
const { getFormattedDateTitle, getMonthName, defaultDate } = useCalendar()
|
||||
const { sortedMonths } = storeToRefs(useCalendar())
|
||||
|
||||
const popoverOpened = ref<boolean>(false)
|
||||
|
||||
const inputPlaceholder = computed<string>(() => {
|
||||
if (model.value) {
|
||||
return getFormattedDateTitle(model.value, true)
|
||||
} else if (props.placeholder) {
|
||||
return props.placeholder
|
||||
}
|
||||
|
||||
return ""
|
||||
})
|
||||
|
||||
const initialDateValue = props.initialDate ? { ...props.initialDate } : null
|
||||
|
||||
const monthTitle = computed<string>(() => {
|
||||
if (model.value) {
|
||||
return getMonthName(model.value.month)
|
||||
}
|
||||
|
||||
return ""
|
||||
})
|
||||
|
||||
const hasValue = computed<boolean>(() => !!model.value)
|
||||
const monthData = computed(() => {
|
||||
if (model.value) {
|
||||
return sortedMonths.value[model.value.month]
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
function setModelDay(day: number) {
|
||||
if (model.value) {
|
||||
model.value.day = day
|
||||
}
|
||||
}
|
||||
|
||||
function handleMonthChange(e: string) {
|
||||
const monthId = Number(e)
|
||||
|
||||
if (model.value) {
|
||||
model.value.month = monthId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click if the date is null
|
||||
*/
|
||||
function handleNullClick() {
|
||||
// Early return
|
||||
if (model.value) return
|
||||
|
||||
if (!model.value) {
|
||||
if (props.initialDate) {
|
||||
model.value = initialDateValue
|
||||
} else {
|
||||
model.value = { ...defaultDate }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleValueReset() {
|
||||
// Early return
|
||||
if (!model.value) return
|
||||
|
||||
model.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiPopover v-model:open="popoverOpened">
|
||||
<UiPopoverTrigger>
|
||||
<div class="flex">
|
||||
<UiButton
|
||||
size='sm'
|
||||
variant="outline"
|
||||
:class="{
|
||||
'text-slate-500': !hasValue,
|
||||
'rounded-tr-none rounded-br-none': model
|
||||
}"
|
||||
@click.prevent="handleNullClick"
|
||||
>
|
||||
{{ inputPlaceholder }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="model && !required"
|
||||
size='sm'
|
||||
variant="outline"
|
||||
class="rounded-tl-none rounded-bl-none px-2"
|
||||
@click.prevent="handleValueReset"
|
||||
>
|
||||
<PhXCircle size="14" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent
|
||||
v-if="model"
|
||||
align="start"
|
||||
side="bottom"
|
||||
class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
|
||||
>
|
||||
<div class="grid grid-cols-2 items-center gap-x-2 gap-y-3">
|
||||
<UiSelect @update:model-value="handleMonthChange">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="monthTitle" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectGroup class="max-h-[50vh]">
|
||||
<UiSelectItem v-for="(month, i) in sortedMonths" :key="`popover-select-item-${id}-${i}`" :value="i.toString()">
|
||||
{{ month.name }}
|
||||
</UiSelectItem>
|
||||
</UiSelectGroup>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
|
||||
<UiInput v-model="model.year" type="number" />
|
||||
|
||||
<!-- <nav class="flex items-center justify-end gap-2">
|
||||
<UiButton variant="outline" size="icon" class="w-8 h-8" @click="decrementModelMonth()">
|
||||
<PhCaretLeft size="14" />
|
||||
</UiButton>
|
||||
<UiButton variant="outline" size="icon" class="w-8 h-8" @click="incrementModelMonth()">
|
||||
<PhCaretRight size="14" />
|
||||
</UiButton>
|
||||
</nav> -->
|
||||
|
||||
<hr class="col-span-2" >
|
||||
|
||||
<div class="col-span-2">
|
||||
<div v-if="monthData" class="grid grid-cols-7 gap-1">
|
||||
<button
|
||||
v-for="day in monthData.days"
|
||||
:key="`popover-day-grid-${id}-${day}`"
|
||||
class="aspect-square rounded-full text-[.8em] transition-colors"
|
||||
:class="{
|
||||
'hover:bg-indigo-200 dark:hover:bg-indigo-700': day !== model.day,
|
||||
'bg-indigo-500 hover:bg-indigo-700 text-white': day === model.day
|
||||
}"
|
||||
@click="setModelDay(day)"
|
||||
>
|
||||
{{ day }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</template>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CalendarMonth } from "~/models/CalendarMonth";
|
||||
import type { RPGDate } from "~/models/Date";
|
||||
|
||||
import { PhCalendarBlank } from "@phosphor-icons/vue";
|
||||
|
||||
const model = defineModel<RPGDate>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
availableMonths: CalendarMonth[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
*
|
||||
* @param e The name of today's month
|
||||
*/
|
||||
function setTodayMonth(e: string) {
|
||||
model.value.month = e
|
||||
}
|
||||
|
||||
// When the model changes, get the month index from the month name
|
||||
watch(model.value, (n, _o) => {
|
||||
// If the month value is already an index, return early
|
||||
if (!isNaN(+model.value.month)) return
|
||||
|
||||
const monthId = props.availableMonths.findIndex((m) => m.name === n.month)
|
||||
|
||||
if (monthId !== -1) {
|
||||
model.value.month = monthId + 1
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-2 items-stretch">
|
||||
<UiInput id="new-month-current-day" v-model="model.day" type="number" name="newMonthCurrentDay" :placeholder="$t('entity.calendar.months.daysNameSingular')" min="1" step="1" class="invalid:border-red-600" />
|
||||
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child :disabled="props.availableMonths.length < 1">
|
||||
<UiButton size="sm" variant="secondary">
|
||||
<PhCalendarBlank size="18" weight="fill" />
|
||||
|
||||
<template v-if="props.availableMonths.length < 1">
|
||||
{{ $t('entity.calendar.months.noneAvailable') }}
|
||||
</template>
|
||||
<template v-else-if="model.month && typeof model.month === 'number'">
|
||||
{{ props.availableMonths[model.month - 1].name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('entity.calendar.months.chooseOne') }}
|
||||
</template>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent :side="'bottom'" :collision-padding="30">
|
||||
<UiDropdownMenuLabel>
|
||||
{{ $t('entity.calendar.months.available') }}
|
||||
</UiDropdownMenuLabel>
|
||||
<UiDropdownMenuSeparator />
|
||||
<UiDropdownMenuItem
|
||||
v-for="m in props.availableMonths"
|
||||
:key="m.name"
|
||||
@click="setTodayMonth(m.name)"
|
||||
>
|
||||
{{ m.name }}
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
|
||||
<UiInput id="new-month-current-day" v-model="model.year" type="number" name="newMonthCurrentYear" :placeholder="$t('entity.calendar.years.nameSingular')" step="1" class="invalid:border-red-600" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,451 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
isCharacter,
|
||||
type Character,
|
||||
} from "@/models/Characters"
|
||||
import type { RPGDateOrder } from "@/models/Date"
|
||||
import {
|
||||
isCalendarEvent,
|
||||
type CalendarEvent,
|
||||
} from "~/models/CalendarEvent"
|
||||
import { capitalize } from "@/utils/Strings"
|
||||
import { useMagicKeys, useScroll, useStorage, whenever } from "@vueuse/core"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { searchUnifier, type SearchMode } from "../SearchMode"
|
||||
|
||||
import { PhCaretDown, PhClockClockwise, PhClockCounterClockwise, PhMagnifyingGlass } from "@phosphor-icons/vue"
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxInput,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
VisuallyHidden
|
||||
} from "radix-vue"
|
||||
|
||||
import SearchList from "./lists/SearchList.vue"
|
||||
import type { Category } from "~/models/Category"
|
||||
|
||||
const { isAdvancedSearchOpen, allEvents } = storeToRefs(useCalendar())
|
||||
const { characters } = storeToRefs(useCharacters())
|
||||
|
||||
const searchQuery = ref<string>("")
|
||||
// const searchEnough = computed<boolean>(() => searchQuery.value.length >= 2)
|
||||
|
||||
// If the query changes, resets the pagination
|
||||
// This prevents active page to be greater than the search results
|
||||
watch(searchQuery, resetPage)
|
||||
|
||||
const selectedEntity = useStorage("se", "events" as SearchMode)
|
||||
|
||||
// Order
|
||||
const selectedOrder = ref<RPGDateOrder>("asc")
|
||||
function setOrderAsc() {
|
||||
selectedOrder.value = "asc"
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function setOrderDesc() {
|
||||
selectedOrder.value = "desc"
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// Limit
|
||||
const currentPage = ref<number>(1)
|
||||
const itemsPerPage: number = 20
|
||||
const startOfList = computed<number>(() => (currentPage.value - 1) * itemsPerPage)
|
||||
const endOfList = computed<number>(() => startOfList.value + itemsPerPage)
|
||||
|
||||
/**
|
||||
* Resets the pagination
|
||||
*/
|
||||
function resetPage() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const searchResults = computed<(Character | CalendarEvent)[]>(() => {
|
||||
let results: (Character | CalendarEvent)[] = []
|
||||
let dataToFilter: Character[] | CalendarEvent[] | (Character | CalendarEvent)[]
|
||||
const shouldFilterCategories = selectedCategories.value.length > 0
|
||||
|
||||
// Assign data to loop over and filter
|
||||
// They are assigned this way for readability
|
||||
if (selectedEntity.value === "events") {
|
||||
dataToFilter = allEvents.value
|
||||
} else if (selectedEntity.value === "characters") {
|
||||
dataToFilter = characters.value
|
||||
} else {
|
||||
dataToFilter = [...allEvents.value, ...characters.value]
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Refactor the categories logic, basically extract the return out of the ifs, like above
|
||||
*/
|
||||
results = dataToFilter.filter((item) => {
|
||||
// Filter calendar events
|
||||
if (isCalendarEvent(item)) {
|
||||
const queryString = new String(searchQuery.value)
|
||||
.replace(searchUnifier, "")
|
||||
.toLocaleLowerCase()
|
||||
|
||||
const hitTitle = item.title
|
||||
.replace(searchUnifier, "")
|
||||
.toLocaleLowerCase()
|
||||
.includes(queryString)
|
||||
const hitDesc = item.description
|
||||
?.replace(searchUnifier, "")
|
||||
.toLocaleLowerCase()
|
||||
.includes(queryString)
|
||||
|
||||
if (!shouldFilterCategories) {
|
||||
return hitTitle || hitDesc
|
||||
}
|
||||
|
||||
// Handle categories logic
|
||||
let hitCategories: boolean = false
|
||||
const allCategories: Category[] = []
|
||||
|
||||
if (item.category) {
|
||||
allCategories.push(item.category)
|
||||
}
|
||||
|
||||
if (item.secondaryCategories && item.secondaryCategories?.length > 0) {
|
||||
allCategories.push(...item.secondaryCategories)
|
||||
}
|
||||
|
||||
hitCategories = selectedCategories.value.every((selectedCat) => {
|
||||
return allCategories.findIndex((c) => c.name === selectedCat.name) !== -1
|
||||
})
|
||||
|
||||
return (hitTitle || hitDesc) && hitCategories
|
||||
}
|
||||
|
||||
// Filter characters
|
||||
if (isCharacter(item)) {
|
||||
const queryString = new String(searchQuery.value)
|
||||
.replace(searchUnifier, "")
|
||||
.toLocaleLowerCase()
|
||||
|
||||
const hitTitle = item.name
|
||||
.replace(searchUnifier, "")
|
||||
.toLocaleLowerCase()
|
||||
.includes(queryString)
|
||||
|
||||
if (!shouldFilterCategories) {
|
||||
return hitTitle
|
||||
}
|
||||
|
||||
// Handle categories logic
|
||||
let hitCategories: boolean = false
|
||||
const allCategories: Category[] = []
|
||||
|
||||
if (item.category) {
|
||||
allCategories.push(item.category)
|
||||
}
|
||||
|
||||
if (item.secondaryCategories && item.secondaryCategories?.length > 0) {
|
||||
allCategories.push(...item.secondaryCategories)
|
||||
}
|
||||
|
||||
hitCategories = selectedCategories.value.every((selectedCat) => {
|
||||
return allCategories.includes(selectedCat as Category)
|
||||
})
|
||||
|
||||
return hitTitle && hitCategories
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
/**
|
||||
* Removes the search query, resets the pagination and removes all selected categories
|
||||
*/
|
||||
function resetSearch() {
|
||||
searchQuery.value = ""
|
||||
resetPage()
|
||||
selectedCategories.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the search Uidialog
|
||||
*/
|
||||
function openUiDialog() {
|
||||
isAdvancedSearchOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the search Uidialog
|
||||
*/
|
||||
function closeUiDialog() {
|
||||
isAdvancedSearchOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the selectedEntity
|
||||
*/
|
||||
function handleEntitySwitch() {
|
||||
resetPage()
|
||||
selectedCategories.value = []
|
||||
}
|
||||
|
||||
// Key combos to deploy modal
|
||||
const keys = useMagicKeys()
|
||||
|
||||
whenever(keys.control_period, () => {
|
||||
openUiDialog()
|
||||
})
|
||||
|
||||
const searchResultsRef = ref<HTMLElement | null>(null)
|
||||
const { y: searchResultsY } = useScroll(searchResultsRef)
|
||||
|
||||
watch([currentPage, selectedEntity], () => {
|
||||
searchResultsY.value = 0
|
||||
})
|
||||
|
||||
// Compute categories based on current selectedEntity
|
||||
const { data: resCategories } = await useFetch("/api/calendars/categories/query")
|
||||
const currentCategories = ref<Category[]>(resCategories.value?.data as Category[])
|
||||
|
||||
const selectedCategories = ref<(Category)[]>([])
|
||||
const categoryFilterOpened = ref<boolean>(false)
|
||||
const searchCategory = ref<string>("")
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
// Display original data
|
||||
if (!currentCategories.value) return []
|
||||
|
||||
// If current categories are selected, ignore them
|
||||
if (!searchCategory.value) return currentCategories.value.filter((i) => !selectedCategories.value.includes(i))
|
||||
|
||||
// If we also have a query to filter them by, do that
|
||||
return currentCategories.value.filter((i) => {
|
||||
return !selectedCategories.value.includes(i) && i.name.toLocaleLowerCase().includes(searchCategory.value.toLocaleLowerCase())
|
||||
})
|
||||
})
|
||||
|
||||
// Reactivity rules for category searches
|
||||
const categoryInput = ref(null)
|
||||
const categoryInputValue = ref<string>("")
|
||||
const { focused: categoryInputFocused } = useFocus(categoryInput)
|
||||
|
||||
watch(categoryInputFocused, (isFocused) => {
|
||||
categoryFilterOpened.value = isFocused
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles the category selections from the TagInput component
|
||||
*
|
||||
* @param e Radix Change Event
|
||||
*/
|
||||
function handleCategorySelect(e: Category) {
|
||||
searchCategory.value = ""
|
||||
selectedCategories.value.push(e)
|
||||
|
||||
if (filteredCategories.value.length === 0) {
|
||||
categoryFilterOpened.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a category from selection from the TagInput component
|
||||
*/
|
||||
function handleCategoryUnselect(e: Category) {
|
||||
selectedCategories.value.splice(selectedCategories.value.findIndex(sc => sc.name === e.name), 1)
|
||||
|
||||
if (filteredCategories.value.length === 0) {
|
||||
categoryFilterOpened.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiDialog v-model:open="isAdvancedSearchOpen" @update:open="resetSearch()">
|
||||
<UiDialogContent
|
||||
class="flex flex-col flex-nowrap top-16 -translate-y-0 data-[state=closed]:slide-out-to-top-[5%] data-[state=open]:slide-in-from-top-[5%]"
|
||||
:class="{
|
||||
'bottom-16': searchResults.length > 0
|
||||
}"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<UiDialogTitle> {{ $t('entity.advancedSearch.title') }} </UiDialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden>
|
||||
<UiDialogDescription>
|
||||
{{ $t('entity.advancedSearch.title') }}
|
||||
</UiDialogDescription>
|
||||
</VisuallyHidden>
|
||||
|
||||
<!-- UiDialog header -->
|
||||
<div id="searchForm" class="grid gap-3">
|
||||
<div class="relative w-full h-fit">
|
||||
<UiInput
|
||||
id="search"
|
||||
v-model:model-value="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('entity.advancedSearch.ctaPlaceholder')"
|
||||
class="pl-10 py-6 text-lg"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="absolute start-1 inset-y-0 flex items-center justify-center px-2 opacity-50">
|
||||
<PhMagnifyingGlass size="20" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-8">
|
||||
<div>
|
||||
<UiToggleGroup
|
||||
v-model="selectedEntity"
|
||||
type="single"
|
||||
class="justify-start"
|
||||
@update:model-value="handleEntitySwitch()"
|
||||
>
|
||||
<UiToggleGroupItem value="events" aria-label="Uniquement les évènements">
|
||||
{{ $t('entity.calendar.event.namePlural') }}
|
||||
</UiToggleGroupItem>
|
||||
<!-- Not used for now -->
|
||||
<!-- <UiToggleGroupItem value="characters" aria-label="Uniquement les personnages">
|
||||
{{ $t('entity.character.namePlural') }}
|
||||
</UiToggleGroupItem> -->
|
||||
</UiToggleGroup>
|
||||
</div>
|
||||
|
||||
<div class="grow flex justify-end items-center gap-1">
|
||||
<UiTagsInput class="grow px-0 gap-y-1 w-80">
|
||||
<div v-if="selectedCategories.length > 0" class="flex gap-2 flex-wrap items-center px-3">
|
||||
<UiTagsInputItem v-for="item in selectedCategories" :key="item.id" :value="item.name">
|
||||
<UiTagsInputItemText class="capitalize cursor-pointer" @click="handleCategoryUnselect(item)" />
|
||||
</UiTagsInputItem>
|
||||
</div>
|
||||
|
||||
<ComboboxRoot
|
||||
v-model="selectedCategories"
|
||||
v-model:open="categoryFilterOpened"
|
||||
v-model:searchTerm="searchCategory"
|
||||
class="grow flex items-center gap-y-1 pr-2"
|
||||
>
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxInput :placeholder="$t('entity.category.namePlural')" as-child>
|
||||
<UiTagsInputInput
|
||||
ref="categoryInput"
|
||||
v-model="categoryInputValue"
|
||||
class="min-w-16 px-3"
|
||||
@keydown.enter.prevent
|
||||
/>
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxTrigger>
|
||||
<PhCaretDown size="16" />
|
||||
</ComboboxTrigger>
|
||||
|
||||
<ComboboxPortal :to="'#searchForm'">
|
||||
<UiCommandList
|
||||
position="popper"
|
||||
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50"
|
||||
:dismissable="true"
|
||||
>
|
||||
<UiCommandEmpty />
|
||||
<UiCommandGroup>
|
||||
<UiCommandItem
|
||||
v-for="cat in filteredCategories"
|
||||
:key="cat.name"
|
||||
:value="cat"
|
||||
@select.prevent="handleCategorySelect(cat)"
|
||||
>
|
||||
{{ capitalize(cat.name) }}
|
||||
</UiCommandItem>
|
||||
</UiCommandGroup>
|
||||
</UiCommandList>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</UiTagsInput>
|
||||
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton
|
||||
:variant="selectedOrder === 'desc' ? 'secondary' : 'outline'"
|
||||
size="icon"
|
||||
@click="setOrderDesc()"
|
||||
>
|
||||
<PhClockCounterClockwise size="18" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ $t('entity.advancedSearch.older') }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton
|
||||
:variant="selectedOrder === 'asc' ? 'secondary' : 'outline'"
|
||||
size="icon"
|
||||
@click="setOrderAsc()"
|
||||
>
|
||||
<PhClockClockwise size="18" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>{{ $t('entity.advancedSearch.newer') }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr >
|
||||
|
||||
<div v-if="searchResults.length > 0" ref="searchResultsRef" class="grow overflow-y-auto">
|
||||
<SearchList
|
||||
:results="searchResults"
|
||||
:current-entity="selectedEntity"
|
||||
:order="selectedOrder"
|
||||
:start-at="startOfList"
|
||||
:end-at="endOfList"
|
||||
@jumped-to-date="closeUiDialog()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<UiPagination
|
||||
v-model:page="currentPage"
|
||||
:total="searchResults.length"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="2"
|
||||
show-edges
|
||||
:default-page="1"
|
||||
>
|
||||
<UiPaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<UiPaginationFirst />
|
||||
<UiPaginationPrev />
|
||||
|
||||
<template v-for="(item, index) in items">
|
||||
<UiPaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:key="index"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<UiButton
|
||||
class="w-10 h-10 p-0"
|
||||
:variant="item.value === currentPage ? 'default' : 'outline'"
|
||||
>
|
||||
{{ item.value }}
|
||||
</UiButton>
|
||||
</UiPaginationListItem>
|
||||
<UiPaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
|
||||
<UiPaginationNext />
|
||||
<UiPaginationLast />
|
||||
</UiPaginationList>
|
||||
</UiPagination>
|
||||
</div>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { RPGDate } from "@/models/Date"
|
||||
import type { CalendarEvent } from "@/models/CalendarEvent"
|
||||
|
||||
import { PhArrowSquareOut, PhHourglassMedium, PhAlarm, PhMapPinArea, PhEye } from "@phosphor-icons/vue"
|
||||
|
||||
const props = defineProps<{
|
||||
event: CalendarEvent
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: "query:date-jump", payload: RPGDate): void
|
||||
}>()
|
||||
|
||||
const { getRelativeString, defaultDate, getFormattedDateTitle } = useCalendar()
|
||||
|
||||
const dateDifference: string = getRelativeString(defaultDate, props.event.startDate)
|
||||
const dateDuration = computed<string | null>(() => props.event.endDate ? getRelativeString(props.event.startDate, props.event.endDate, "compact") : null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="relative block w-full text-left py-3 px-4 rounded-sm transition-colors"
|
||||
:class="cn(
|
||||
event.category ? `event-${event.category.color}` : '',
|
||||
{
|
||||
'pt-4': event.hidden,
|
||||
})"
|
||||
@click="$emit('query:date-jump', event.startDate)"
|
||||
>
|
||||
<div class="flex gap-2 items-center mb-1">
|
||||
<h2 class="font-bold text-lg">
|
||||
{{ event.title }}
|
||||
</h2>
|
||||
<div v-if="event.wiki">
|
||||
<UiButton variant="link" size="xs" as-child class="text-inherit">
|
||||
<a :href="event.wiki" target="_blank">
|
||||
Wiki
|
||||
<PhArrowSquareOut size="16" weight="fill" />
|
||||
</a>
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center justify-between mb-1">
|
||||
<template v-if="!event.endDate">
|
||||
<p class="font-semibold text-sm opacity-75">
|
||||
{{ getFormattedDateTitle(event.startDate, true) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="font-semibold text-sm opacity-75">
|
||||
{{
|
||||
$t('entity.calendar.date.fromTo',
|
||||
{
|
||||
startDate: getFormattedDateTitle(event.startDate, true),
|
||||
endDate: getFormattedDateTitle(event.endDate, true)
|
||||
}
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<div v-if="event.hidden" class="flex justify-end">
|
||||
<UiTooltipProvider :delay-duration="250">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiBadge class="flex gap-1">
|
||||
<PhEye size="16" weight="fill" /> {{ $t('entity.calendar.event.isHidden') }}
|
||||
</UiBadge>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>
|
||||
{{ $t('entity.calendar.event.hiddenTooltip') }}
|
||||
</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-1 flex gap-x-2 items-center">
|
||||
<template v-if="event.location">
|
||||
<p class="w-fit text-sm italic opacity-75 flex items-center gap-1">
|
||||
<PhMapPinArea size="16" weight="fill" /> {{ event.location }}
|
||||
</p>
|
||||
</template>
|
||||
<p class="w-fit text-sm italic opacity-75 flex items-center gap-1">
|
||||
<PhAlarm size="16" weight="fill" /> {{ dateDifference }}
|
||||
</p>
|
||||
<template v-if="dateDuration">
|
||||
<p class="w-fit text-sm italic opacity-75 flex items-center gap-1">
|
||||
<PhHourglassMedium size="16" weight="fill" /> {{ $t('entity.calendar.date.while', { duration: dateDuration } )}}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="event.category || event.secondaryCategories" class="absolute top-3 right-4">
|
||||
<ul class="flex gap-1">
|
||||
<li v-if="event.category">
|
||||
<UiBadge class="mix-blend-luminosity font-bold bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
|
||||
{{ event.category?.name }}
|
||||
</UiBadge>
|
||||
</li>
|
||||
|
||||
<li v-for="cat in event.secondaryCategories" :key="cat.id">
|
||||
<UiBadge class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
|
||||
{{ cat.name }}
|
||||
</UiBadge>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="event.description" class="text-sm">
|
||||
<hr class="my-2 border-white opacity-50" >
|
||||
<span class="opacity-75">
|
||||
{{ event.description }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,180 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RPGDate } from "@/models/Date"
|
||||
import type { CalendarEvent } from "@/models/CalendarEvent"
|
||||
import { useElementBounding } from "@vueuse/core"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { computed, ref, type ComputedRef } from "vue"
|
||||
|
||||
import CalendarEventButton from "../../CalendarEvent.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
date: RPGDate
|
||||
faded?: boolean
|
||||
}>()
|
||||
|
||||
const calendarTile = ref()
|
||||
const calendarEventsList = ref()
|
||||
|
||||
const { defaultDate, selectDate, areDatesIdentical } = useCalendar()
|
||||
const { selectedDate, currentEvents } = storeToRefs(useCalendar())
|
||||
|
||||
/**
|
||||
* All events with a startDate / endDate that starts or ends on the tile
|
||||
*/
|
||||
const eventsForTheDay = computed(() => {
|
||||
return currentEvents.value.filter((currentEvent) => {
|
||||
return (
|
||||
areDatesIdentical(currentEvent.startDate, props.date) ||
|
||||
areDatesIdentical(currentEvent.endDate!, props.date)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Is the tile on today's date ?
|
||||
*/
|
||||
const isDefaultDate = computed(() => {
|
||||
return areDatesIdentical(props.date, defaultDate)
|
||||
})
|
||||
|
||||
/**
|
||||
* Is the tile on the hightlighted date ?
|
||||
*/
|
||||
const isSelectedDate = computed(() => {
|
||||
return areDatesIdentical(props.date, selectedDate.value)
|
||||
})
|
||||
|
||||
// Get bounding elements for both tile and events list
|
||||
const { height: tileHeight, top: tileTop } = useElementBounding(calendarTile)
|
||||
const { top: tileListTop } = useElementBounding(calendarEventsList)
|
||||
|
||||
// Compute the available number of events that can be displayed from refs heights
|
||||
const numberOfEventsToFit: ComputedRef<number> = computed(() => {
|
||||
if (!eventsForTheDay.value.length) return 0
|
||||
|
||||
return Math.trunc((tileHeight.value - (tileListTop.value - tileTop.value)) / 40)
|
||||
})
|
||||
|
||||
/**
|
||||
* Events that can fit in the tile's space
|
||||
*/
|
||||
const eventsToDisplay: ComputedRef<CalendarEvent[]> = computed<CalendarEvent[]>(() => {
|
||||
return [...eventsForTheDay.value].splice(0, numberOfEventsToFit.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Remaining events that are not displayed
|
||||
*
|
||||
* Used if not all events can be seen in the tile's space
|
||||
*/
|
||||
const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsForTheDay.value.length - eventsToDisplay.value.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="calendarTile"
|
||||
class="tile relative text-xs p-2 border-slate-400 dark:border-slate-700 dark:bg-black transition-colors"
|
||||
:class="{
|
||||
'text-slate-300 dark:text-slate-500': props.faded,
|
||||
'text-slate-500 dark:text-slate-300': !props.faded
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="relative z-10 group block w-full text-center cursor-pointer"
|
||||
@click="selectDate(date)"
|
||||
>
|
||||
<ClientOnly>
|
||||
<span
|
||||
class="inline-flex w-8 h-8 aspect-square items-center justify-center rounded-full border-2 border-transparent font-bold transition-colors group-hover:border-indigo-300 dark:group-hover:border-indigo-700"
|
||||
:class="{
|
||||
'text-white bg-slate-600 dark:bg-slate-800': isDefaultDate && !isSelectedDate,
|
||||
'text-white bg-indigo-500': isSelectedDate
|
||||
}"
|
||||
>
|
||||
{{ date.day }}
|
||||
</span>
|
||||
</ClientOnly>
|
||||
</button>
|
||||
|
||||
<ClientOnly>
|
||||
<ul
|
||||
ref="calendarEventsList"
|
||||
class="absolute top-12 bottom-2 inset-x-2 grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity"
|
||||
:class="{
|
||||
'opacity-40': props.faded && !isSelectedDate
|
||||
}"
|
||||
>
|
||||
<TransitionGroup name="event">
|
||||
<li v-for="event in eventsToDisplay" :key="event.id" class="grid w-full pointer-events-auto">
|
||||
<CalendarEventButton :event :tile-date="date" />
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
|
||||
<li v-if="eventsNotDisplayed > 0" class="pointer-events-auto">
|
||||
<UiPopover>
|
||||
<UiPopoverTrigger as-child>
|
||||
<button
|
||||
class="text-xs px-2 py-1 block w-full text-left font-bold rounded-sm whitespace-nowrap overflow-hidden text-ellipsis cursor-pointer transition-colors hover:bg-slate-800"
|
||||
>
|
||||
{{ eventsNotDisplayed }} autre{{ eventsNotDisplayed > 1 ? 's' : '' }}
|
||||
</button>
|
||||
</UiPopoverTrigger>
|
||||
<UiPopoverContent class="w-80" :align="'center'" :side="'right'">
|
||||
<div class="text-center mb-4">
|
||||
<span
|
||||
class="inline-flex w-12 h-12 aspect-square items-center justify-center text-lg font-semibold text-slate-300 bg-slate-800 rounded-full"
|
||||
>
|
||||
{{ date.day }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity">
|
||||
<li
|
||||
v-for="event in eventsForTheDay"
|
||||
:key="event.title"
|
||||
class="grid pointer-events-auto"
|
||||
>
|
||||
<CalendarEventButton :event :tile-date="date" />
|
||||
</li>
|
||||
</ul>
|
||||
</UiPopoverContent>
|
||||
</UiPopover>
|
||||
</li>
|
||||
</ul>
|
||||
</ClientOnly>
|
||||
|
||||
<ClientOnly>
|
||||
<LazyCalendarFormCreateEvent :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tile {
|
||||
&:not(:nth-child(10n)) {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
|
||||
&:nth-child(n + 11) {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.event-enter-active {
|
||||
transition: all 120ms ease-in-out;
|
||||
}
|
||||
.event-move,
|
||||
.event-leave-active {
|
||||
position: absolute;
|
||||
transition: all 200ms ease-in-out;
|
||||
}
|
||||
.event-enter-from,
|
||||
.event-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.event-enter-from {
|
||||
transform: translateX(.15rem);
|
||||
}
|
||||
.event-leave-to {
|
||||
transform: translateX(-.5rem);
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { useCalendar } from "@/stores/CalendarStore"
|
||||
import { useThrottleFn } from "@vueuse/core"
|
||||
|
||||
const { currentDate, decrementViewMonth, incrementViewMonth } = useCalendar()
|
||||
const { currentMonthData } = storeToRefs(useCalendar())
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const isMovingUp = e.deltaY < 0
|
||||
if (isMovingUp) {
|
||||
moveCalendarLeft()
|
||||
} else {
|
||||
moveCalendarRight()
|
||||
}
|
||||
}
|
||||
|
||||
const moveCalendarLeft = useThrottleFn(() => {
|
||||
decrementViewMonth()
|
||||
}, 100)
|
||||
|
||||
const moveCalendarRight = useThrottleFn(() => {
|
||||
incrementViewMonth()
|
||||
}, 100)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-10" @wheel="handleWheel">
|
||||
<template v-if="currentMonthData">
|
||||
<CalendarStateMonthlyDayTile
|
||||
v-for="day in currentMonthData?.days"
|
||||
:key="`layout-month-grid-${day}`"
|
||||
:date="{
|
||||
day: day,
|
||||
month: currentDate.currentMonth,
|
||||
year: currentDate.currentYear
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { useCalendar } from "@/stores/CalendarStore"
|
||||
import { useThrottleFn } from "@vueuse/core"
|
||||
|
||||
const { decrementViewYear, incrementViewYear } = useCalendar()
|
||||
const { sortedMonths: months } = storeToRefs(useCalendar())
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const isMovingUp = e.deltaY < 0
|
||||
if (isMovingUp) {
|
||||
moveCalendarLeft()
|
||||
} else {
|
||||
moveCalendarRight()
|
||||
}
|
||||
}
|
||||
|
||||
const moveCalendarLeft = useThrottleFn(() => {
|
||||
decrementViewYear()
|
||||
}, 100)
|
||||
|
||||
const moveCalendarRight = useThrottleFn(() => {
|
||||
incrementViewYear()
|
||||
}, 100)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mt-[10vh] mb-auto" @wheel="handleWheel">
|
||||
<div class="grid grid-cols-5 gap-x-8 gap-y-16">
|
||||
<CalendarStateYearlyMonthTile
|
||||
v-for="month in months"
|
||||
:key="month.id"
|
||||
:month
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CalendarMonth } from "~/models/CalendarMonth";
|
||||
|
||||
defineProps<{
|
||||
month: CalendarMonth
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ month.name }}
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<CalendarStateYearlyDayTile
|
||||
v-for="day in month.days"
|
||||
:key="day"
|
||||
:month-number="month.position"
|
||||
:day-number="day"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
type HeadingLevel = "h1" | "h2" | "h3"
|
||||
|
||||
interface HeadingProps {
|
||||
level?: HeadingLevel
|
||||
}
|
||||
|
||||
withDefaults(defineProps<HeadingProps>(), {
|
||||
level: "h2"
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 v-if="level === 'h1'" class="text-4xl font-bold flex">
|
||||
<slot />
|
||||
</h1>
|
||||
<h2 v-else-if="level === 'h2'" class="text-2xl font-bold flex">
|
||||
<slot />
|
||||
</h2>
|
||||
<h3 v-if="level === 'h3'" class="text-xl font-bold flex">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface SpacingProps {
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xlg" | "2xl" | "3xl"
|
||||
}
|
||||
|
||||
const props = defineProps<SpacingProps>()
|
||||
|
||||
let spacingClass: string
|
||||
|
||||
switch (props.size) {
|
||||
case "lg":
|
||||
spacingClass = "space-y-4"
|
||||
break;
|
||||
|
||||
case "md":
|
||||
default:
|
||||
spacingClass = "space-y-2"
|
||||
break;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(spacingClass)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { type RPGColor, rpgColors } from "~/models/Color";
|
||||
|
||||
const model = defineModel<RPGColor>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSelect v-model="model">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue :placeholder="$t('ui.colors.selectOne')" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectGroup>
|
||||
<UiSelectItem v-for="color in rpgColors" :key="color" :value="color">
|
||||
{{ $t(`ui.colors.${color}`) }}
|
||||
</UiSelectItem>
|
||||
</UiSelectGroup>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhGlobeHemisphereWest, PhList } from "@phosphor-icons/vue"
|
||||
import type { SidebarMenuActionType } from "./SidebarProps";
|
||||
|
||||
const { revealAdvancedSearch } = useCalendar()
|
||||
const { currentMenu } = storeToRefs(useUiStore())
|
||||
|
||||
const user = useSupabaseUser()
|
||||
|
||||
function handleMenuItemAction(actionType: SidebarMenuActionType) {
|
||||
if (actionType === "event-search") {
|
||||
revealAdvancedSearch()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="w-16 py-6 border-r-[1px] bg-indigo-700 dark:bg-black text-white border-r-indigo-700 dark:border-r-indigo-950 grid grid-rows-[1fr_auto] justify-center transition-colors">
|
||||
<menu class="flex flex-col gap-4">
|
||||
<li class="mb-12">
|
||||
<UiButton variant="ghost" size="icon" class="rounded-full" @click="console.log">
|
||||
<PhList size="27" />
|
||||
</UiButton>
|
||||
</li>
|
||||
|
||||
<li v-if="user">
|
||||
<UiTooltipProvider :delay-duration="50">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton variant="ghost" size="icon" class="rounded-full" as-child>
|
||||
<RouterLink to="/my">
|
||||
<PhGlobeHemisphereWest size="24" weight="fill" />
|
||||
</RouterLink>
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent :side="'right'" :side-offset="6">
|
||||
<p>
|
||||
{{ $t('entity.world.namePlural') }}
|
||||
</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</li>
|
||||
|
||||
<li v-for="(item, i) in currentMenu" :key="i">
|
||||
<UiTooltipProvider :delay-duration="50">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton v-if="item.to" variant="ghost" size="icon" class="rounded-full" as-child>
|
||||
<RouterLink :to="item.to">
|
||||
<component :is="item.phIcon" size="24" weight="fill" />
|
||||
</RouterLink>
|
||||
</UiButton>
|
||||
<UiButton v-if="item.action" variant="ghost" size="icon" class="rounded-full" @click="handleMenuItemAction(item.action!)">
|
||||
<component :is="item.phIcon" size="24" weight="fill" />
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent :side="'right'" :side-offset="6">
|
||||
<p>{{ item.tooltip }}</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
</UiTooltipProvider>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
<UserCTA />
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ShallowRef } from "vue"
|
||||
|
||||
export type SidebarMenuActionType = "event-search"
|
||||
|
||||
export interface SidebarMenuItem {
|
||||
phIcon: ShallowRef // use shallowRef to build phIcon
|
||||
tooltip: string
|
||||
action?: SidebarMenuActionType
|
||||
to?: string
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
menuItems: SidebarMenuItem[],
|
||||
isHome?: boolean
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
|
||||
import { PhCheckCircle, PhGear, PhGlobeHemisphereWest, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle } from "@phosphor-icons/vue"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { auth } = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
const userMeta = computed(() => user.value?.user_metadata)
|
||||
const profileUrl: string = `${useRequestURL().origin}/my/`
|
||||
|
||||
const { locale, setLocale } = useI18n()
|
||||
|
||||
const menuOpened = ref<boolean>(false)
|
||||
|
||||
function closeMenu() {
|
||||
menuOpened.value = false
|
||||
}
|
||||
watch(user, closeMenu)
|
||||
|
||||
async function handleGoogleLogin() {
|
||||
try {
|
||||
auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: {
|
||||
queryParams: {
|
||||
access_type: "offline",
|
||||
prompt: "consent"
|
||||
},
|
||||
redirectTo: profileUrl
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
const { error } = await auth.signOut()
|
||||
|
||||
if (error) throw error
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
type AvailableRoutes = "/my" | "/my/settings"
|
||||
|
||||
function pushRoute(to: AvailableRoutes) {
|
||||
router.push({ path: to })
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<UiDropdownMenu v-model:open="menuOpened">
|
||||
<UiDropdownMenuTrigger>
|
||||
<UiAvatar v-if="user" class="cursor-pointer">
|
||||
<UiAvatarImage
|
||||
:src="userMeta?.avatar_url"
|
||||
:alt="userMeta?.full_name"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<UiAvatarFallback>
|
||||
{{ $t('ui.sidebarMenu.avatarFallback') }}
|
||||
</UiAvatarFallback>
|
||||
</UiAvatar>
|
||||
<UiButton v-else variant="outline" size="icon" class="rounded-full">
|
||||
<PhUserCircle size="24" />
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
|
||||
<UiDropdownMenuContent class="w-72 p-0 pb-1" :align="'start'" :side="'top'" :side-offset="10" :align-offset="25" :collision-padding="40">
|
||||
<template v-if="user">
|
||||
<p class="p-2 text-[.7em] opacity-75">
|
||||
{{ $t('ui.greeting', { user: user?.email }) }}
|
||||
</p>
|
||||
|
||||
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="pushRoute('/my')">
|
||||
<PhGlobeHemisphereWest size="20" weight="fill" />
|
||||
<span>
|
||||
{{ $t('entity.world.namePlural') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="p-2 text-[.7em] opacity-75">
|
||||
{{ $t('ui.anonymousGreeting') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<UiDropdownMenuSub>
|
||||
<UiDropdownMenuSubTrigger class="p-0 rounded-none">
|
||||
<UiDropdownMenuItem class="flex gap-[.5ch] items-center pointer-events-none">
|
||||
<PhPalette size="20" weight="fill" />
|
||||
<span>
|
||||
{{ $t('ui.sidebarMenu.appearance') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuSubTrigger>
|
||||
<UiDropdownMenuPortal>
|
||||
<UiDropdownMenuSubContent>
|
||||
<UiDropdownMenuItem
|
||||
class="flex gap-[.5ch] items-center rounded-none transition-colors"
|
||||
:class="cn({ 'text-emerald-600': $colorMode.preference === 'dark' })"
|
||||
@select.prevent="$colorMode.preference = 'dark'"
|
||||
>
|
||||
<PhCheckCircle v-if="$colorMode.preference === 'dark'" size="20" weight="fill" />
|
||||
<PhMoon v-else size="20" />
|
||||
|
||||
<span>
|
||||
{{ $t('ui.dark') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem
|
||||
class="flex gap-[.5ch] items-center rounded-none transition-colors"
|
||||
:class="cn({ 'text-emerald-600': $colorMode.preference === 'light' })"
|
||||
@select.prevent="$colorMode.preference = 'light'"
|
||||
>
|
||||
<PhCheckCircle v-if="$colorMode.preference === 'light'" size="20" weight="fill" />
|
||||
<PhSun v-else size="20" />
|
||||
|
||||
<span>
|
||||
{{ $t('ui.light') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem
|
||||
class="flex gap-[.5ch] items-center rounded-none transition-colors"
|
||||
:class="cn({ 'text-emerald-600': $colorMode.preference === 'system' })"
|
||||
@select.prevent="$colorMode.preference = 'system'"
|
||||
>
|
||||
<PhCheckCircle v-if="$colorMode.preference === 'system'" size="20" weight="fill" />
|
||||
<PhLaptop v-else size="20" />
|
||||
|
||||
<span>
|
||||
{{ $t('ui.system') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuSubContent>
|
||||
</UiDropdownMenuPortal>
|
||||
</UiDropdownMenuSub>
|
||||
|
||||
<UiDropdownMenuSub>
|
||||
<UiDropdownMenuSubTrigger class="p-0 rounded-none">
|
||||
<UiDropdownMenuItem class="flex gap-[.5ch] items-center pointer-events-none">
|
||||
<PhTranslate size="20" />
|
||||
<span>
|
||||
{{ $t('ui.sidebarMenu.language') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuSubTrigger>
|
||||
<UiDropdownMenuPortal>
|
||||
<UiDropdownMenuSubContent>
|
||||
<UiDropdownMenuSubContent>
|
||||
<UiDropdownMenuItem
|
||||
class="flex gap-[.5ch] items-center rounded-none transition-colors"
|
||||
:class="cn({ 'text-emerald-600': locale === 'fr' })"
|
||||
@select.prevent="setLocale('fr')"
|
||||
>
|
||||
<PhCheckCircle v-if="locale === 'fr'" size="20" weight="fill" />
|
||||
<img v-else src="/images/flag-fr.png" width="20" alt="" loading="eager">
|
||||
<span>Français</span>
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem
|
||||
class="flex gap-[.5ch] items-center rounded-none transition-colors"
|
||||
:class="cn({ 'text-emerald-600': locale === 'en' })"
|
||||
@select.prevent="setLocale('en')"
|
||||
>
|
||||
<PhCheckCircle v-if="locale === 'en'" size="20" weight="fill" />
|
||||
<img v-else src="/images/flag-uk.png" width="20" alt="" loading="eager">
|
||||
<span>English</span>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuSubContent>
|
||||
</UiDropdownMenuSubContent>
|
||||
</UiDropdownMenuPortal>
|
||||
</UiDropdownMenuSub>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
|
||||
<template v-if="user">
|
||||
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="pushRoute('/my/settings')">
|
||||
<PhGear size="20" weight="fill" />
|
||||
<span>
|
||||
{{ $t('ui.sidebarMenu.account') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="handleLogout">
|
||||
<PhSignOut size="20" weight="fill" />
|
||||
<span>
|
||||
{{ $t('ui.sidebarMenu.logout') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
</template>
|
||||
|
||||
<UiDropdownMenuItem v-if="!user" class="flex gap-[.5ch] items-center rounded-none" @click="handleGoogleLogin">
|
||||
<PhSignIn size="18" weight="fill" />
|
||||
<span>
|
||||
{{ $t('ui.sidebarMenu.login') }}
|
||||
</span>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from "radix-vue"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button/index"
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button/index"
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel v-bind="delegatedProps" :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
type AlertDialogContentEmits,
|
||||
type AlertDialogContentProps,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
type AlertDialogDescriptionProps,
|
||||
} from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { AlertDialogTitle, type AlertDialogTitleProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertDialogTrigger, type AlertDialogTriggerProps } from "radix-vue"
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { AvatarRoot } from "radix-vue"
|
||||
import { type AvatarVariants, avatarVariant } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
size?: AvatarVariants["size"]
|
||||
shape?: AvatarVariants["shape"]
|
||||
}>(), {
|
||||
size: "sm",
|
||||
shape: "circle",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
|
||||
export { default as Avatar } from "./Avatar.vue"
|
||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||
|
||||
export const avatarVariant = cva(
|
||||
"inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-10 w-10 text-xs",
|
||||
base: "h-16 w-16 text-2xl",
|
||||
lg: "h-32 w-32 text-5xl",
|
||||
},
|
||||
shape: {
|
||||
circle: "rounded-full",
|
||||
square: "rounded-md",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AvatarVariants = VariantProps<typeof avatarVariant>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { type BadgeVariants, badgeVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -1,32 +0,0 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-30",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
xs: "h-7 rounded-sm px-2 text-xs",
|
||||
sm: "h-9 rounded-md px-3 text-sm",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
:class="
|
||||
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "radix-vue"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "radix-vue"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
|
||||
<slot>
|
||||
<Check class="h-4 w-4" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
@@ -1,11 +0,0 @@
|
||||
export { ComboboxPortal } from "radix-vue"
|
||||
|
||||
export { default as Command } from "./Command.vue"
|
||||
export { default as CommandDialog } from "./CommandDialog.vue"
|
||||
export { default as CommandEmpty } from "./CommandEmpty.vue"
|
||||
export { default as CommandGroup } from "./CommandGroup.vue"
|
||||
export { default as CommandInput } from "./CommandInput.vue"
|
||||
export { default as CommandItem } from "./CommandItem.vue"
|
||||
export { default as CommandList } from "./CommandList.vue"
|
||||
export { default as CommandSeparator } from "./CommandSeparator.vue"
|
||||
export { default as CommandShortcut } from "./CommandShortcut.vue"
|
||||
@@ -1,9 +0,0 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
@@ -1,16 +0,0 @@
|
||||
export { DropdownMenuPortal } from "radix-vue"
|
||||
|
||||
export { default as DropdownMenu } from "./DropdownMenu.vue"
|
||||
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
|
||||
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
|
||||
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
|
||||
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
|
||||
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
|
||||
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
|
||||
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
|
||||
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
|
||||
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
|
||||
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
|
||||
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
|
||||
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
|
||||
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { Label, type LabelProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Label } from "./Label.vue"
|
||||
@@ -1,6 +0,0 @@
|
||||
export { PaginationRoot as Pagination, PaginationList, PaginationListItem } from "radix-vue"
|
||||
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
|
||||
export { default as PaginationFirst } from "./PaginationFirst.vue"
|
||||
export { default as PaginationLast } from "./PaginationLast.vue"
|
||||
export { default as PaginationNext } from "./PaginationNext.vue"
|
||||
export { default as PaginationPrev } from "./PaginationPrev.vue"
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as Popover } from "./Popover.vue"
|
||||
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import {
|
||||
ProgressIndicator,
|
||||
ProgressRoot,
|
||||
type ProgressRootProps,
|
||||
} from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
modelValue: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProgressRoot
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-secondary',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ProgressIndicator
|
||||
class="h-full w-full flex-1 bg-primary transition-all"
|
||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Progress } from "./Progress.vue"
|
||||
@@ -1,11 +0,0 @@
|
||||
export { default as Select } from "./Select.vue"
|
||||
export { default as SelectValue } from "./SelectValue.vue"
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||
export { default as SelectContent } from "./SelectContent.vue"
|
||||
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||
export { default as SelectItem } from "./SelectItem.vue"
|
||||
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SkeletonProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = defineProps<SkeletonProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('animate-pulse rounded-md bg-muted', props.class)" />
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Skeleton } from "./Skeleton.vue"
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import {
|
||||
SwitchRoot,
|
||||
type SwitchRootEmits,
|
||||
type SwitchRootProps,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0')"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as Switch } from "./Switch.vue"
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Tabs } from "./Tabs.vue"
|
||||
export { default as TabsTrigger } from "./TabsTrigger.vue"
|
||||
export { default as TabsList } from "./TabsList.vue"
|
||||
export { default as TabsContent } from "./TabsContent.vue"
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default as TagsInput } from "./TagsInput.vue"
|
||||
export { default as TagsInputInput } from "./TagsInputInput.vue"
|
||||
export { default as TagsInputItem } from "./TagsInputItem.vue"
|
||||
export { default as TagsInputItemDelete } from "./TagsInputItemDelete.vue"
|
||||
export { default as TagsInputItemText } from "./TagsInputItemText.vue"
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { ToastRoot, type ToastRootEmits, useForwardPropsEmits } from "radix-vue"
|
||||
import { type ToastProps, toastVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToastProps>()
|
||||
|
||||
const emits = defineEmits<ToastRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn(toastVariants({ variant }), props.class)"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<slot />
|
||||
</ToastRoot>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { ToastAction, type ToastActionProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToastActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastAction v-bind="delegatedProps" :class="cn('inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', props.class)">
|
||||
<slot />
|
||||
</ToastAction>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { ToastClose, type ToastCloseProps } from "radix-vue"
|
||||
import { X } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToastCloseProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastClose v-bind="delegatedProps" :class="cn('absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', props.class)">
|
||||
<X class="h-4 w-4" />
|
||||
</ToastClose>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { ToastDescription, type ToastDescriptionProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToastDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastDescription :class="cn('text-sm opacity-90', props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</ToastDescription>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ToastProvider, type ToastProviderProps } from "radix-vue"
|
||||
|
||||
const props = defineProps<ToastProviderProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider v-bind="props">
|
||||
<slot />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { ToastTitle, type ToastTitleProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToastTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastTitle v-bind="delegatedProps" :class="cn('text-[.85em] font-semibold', props.class)">
|
||||
<slot />
|
||||
</ToastTitle>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from "vue"
|
||||
import { ToastViewport, type ToastViewportProps } from "radix-vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToastViewportProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastViewport v-bind="delegatedProps" :class="cn('fixed top-0 z-[100] flex gap-y-2 max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', props.class)" />
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { isVNode } from "vue"
|
||||
import { useToast } from "./use-toast"
|
||||
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "."
|
||||
|
||||
const { toasts } = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<Toast v-for="toast in toasts" :key="toast.id" v-bind="toast">
|
||||
<div class="grid gap-1">
|
||||
<ToastTitle v-if="toast.title">
|
||||
{{ toast.title }}
|
||||
</ToastTitle>
|
||||
<template v-if="toast.description">
|
||||
<ToastDescription v-if="isVNode(toast.description)">
|
||||
<component :is="toast.description" />
|
||||
</ToastDescription>
|
||||
<ToastDescription v-else>
|
||||
{{ toast.description }}
|
||||
</ToastDescription>
|
||||
</template>
|
||||
<ToastClose />
|
||||
</div>
|
||||
<component :is="toast.action" />
|
||||
</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { ToastRootProps } from "radix-vue"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
|
||||
export { default as Toaster } from "./Toaster.vue"
|
||||
export { default as Toast } from "./Toast.vue"
|
||||
export { default as ToastViewport } from "./ToastViewport.vue"
|
||||
export { default as ToastAction } from "./ToastAction.vue"
|
||||
export { default as ToastClose } from "./ToastClose.vue"
|
||||
export { default as ToastTitle } from "./ToastTitle.vue"
|
||||
export { default as ToastDescription } from "./ToastDescription.vue"
|
||||
export { default as ToastProvider } from "./ToastProvider.vue"
|
||||
export { toast, useToast } from "./use-toast"
|
||||
|
||||
export const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[--radix-toast-swipe-end-x] data-[swipe=move]:translate-x-[--radix-toast-swipe-move-x] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "success group border-success bg-success-muted text-success-foreground"
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type ToastVariants = VariantProps<typeof toastVariants>
|
||||
|
||||
export interface ToastProps extends ToastRootProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: ToastVariants["variant"]
|
||||
onOpenChange?: ((value: boolean) => void) | undefined
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { computed, ref } from "vue"
|
||||
import type { Component, VNode } from "vue"
|
||||
import type { ToastProps } from "."
|
||||
|
||||
const TOAST_LIMIT = 3
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
export type StringOrVNode =
|
||||
| string
|
||||
| VNode
|
||||
| (() => VNode)
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: string
|
||||
description?: StringOrVNode
|
||||
action?: Component
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function addToRemoveQueue(toastId: string) {
|
||||
if (toastTimeouts.has(toastId))
|
||||
return
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: actionTypes.REMOVE_TOAST,
|
||||
toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
const state = ref<State>({
|
||||
toasts: [],
|
||||
})
|
||||
|
||||
function dispatch(action: Action) {
|
||||
switch (action.type) {
|
||||
case actionTypes.ADD_TOAST:
|
||||
state.value.toasts = [action.toast, ...state.value.toasts].slice(0, TOAST_LIMIT)
|
||||
break
|
||||
|
||||
case actionTypes.UPDATE_TOAST:
|
||||
state.value.toasts = state.value.toasts.map(t =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
)
|
||||
break
|
||||
|
||||
case actionTypes.DISMISS_TOAST: {
|
||||
const { toastId } = action
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
}
|
||||
else {
|
||||
state.value.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
state.value.toasts = state.value.toasts.map(t =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case actionTypes.REMOVE_TOAST:
|
||||
if (action.toastId === undefined)
|
||||
state.value.toasts = []
|
||||
else
|
||||
state.value.toasts = state.value.toasts.filter(t => t.id !== action.toastId)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
return {
|
||||
toasts: computed(() => state.value.toasts),
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast(props: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: actionTypes.UPDATE_TOAST,
|
||||
toast: { ...props, id },
|
||||
})
|
||||
|
||||
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: actionTypes.ADD_TOAST,
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open)
|
||||
dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
export { toast, useToast }
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as ToggleGroup } from "./ToggleGroup.vue"
|
||||
export { default as ToggleGroupItem } from "./ToggleGroupItem.vue"
|
||||
@@ -1,26 +0,0 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
|
||||
export { default as Toggle } from "./Toggle.vue"
|
||||
|
||||
export const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground"
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export type ToggleVariants = VariantProps<typeof toggleVariants>
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as Tooltip } from "./Tooltip.vue"
|
||||
export { default as TooltipContent } from "./TooltipContent.vue"
|
||||
export { default as TooltipTrigger } from "./TooltipTrigger.vue"
|
||||
export { default as TooltipProvider } from "./TooltipProvider.vue"
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhX } from "@phosphor-icons/vue";
|
||||
|
||||
defineProps<{
|
||||
modalState?: boolean
|
||||
}>()
|
||||
|
||||
const worldSkeletonName = ref<string>("")
|
||||
|
||||
function onChangedName(newName: string) {
|
||||
worldSkeletonName.value = newName
|
||||
}
|
||||
|
||||
const emit = defineEmits(["on-close"])
|
||||
|
||||
function handleClose() {
|
||||
emit("on-close")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiAlertDialog :open="modalState">
|
||||
<UiAlertDialogContent class="gap-4">
|
||||
<header>
|
||||
<UiAlertDialogTitle>
|
||||
<span class="text-2xl">
|
||||
<span v-if="worldSkeletonName">
|
||||
{{ $t('entity.world.createDialog.title') }} — {{ worldSkeletonName }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('entity.world.createDialog.title') }}
|
||||
</span>
|
||||
</span>
|
||||
</UiAlertDialogTitle>
|
||||
|
||||
<UiAlertDialogDescription>
|
||||
{{ $t('entity.world.createDialog.subtitle') }}
|
||||
</UiAlertDialogDescription>
|
||||
</header>
|
||||
|
||||
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClose">
|
||||
<PhX size="20" />
|
||||
</UiButton>
|
||||
|
||||
<WorldFormCreate @on-changed-name="onChangedName" @on-close="handleClose" />
|
||||
</UiAlertDialogContent>
|
||||
</UiAlertDialog>
|
||||
</template>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCircleNotch } from "@phosphor-icons/vue";
|
||||
import { useToast } from "~/components/ui/toast";
|
||||
import type { World } from "~/models/World";
|
||||
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modalState: boolean,
|
||||
world: World | null
|
||||
}>()
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const emit = defineEmits(["on-close"])
|
||||
|
||||
async function handleAction(): Promise<void> {
|
||||
if (isLoading.value) return
|
||||
if (!props.world) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await $fetch(`/api/worlds/${props.world.id}`, { method: "DELETE" })
|
||||
emit("on-close")
|
||||
|
||||
toast({
|
||||
title: t("entity.world.deletedToast.title", { world: props.world.name }),
|
||||
variant: "success",
|
||||
duration: 3000
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
if (err instanceof Error) {
|
||||
toast({
|
||||
title: err.message,
|
||||
variant: "destructive"
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the modal from closing if's still loading
|
||||
*
|
||||
* @param e The closing event (can be keydown or click)
|
||||
*/
|
||||
function handleClosing() {
|
||||
if (!isLoading.value) {
|
||||
emit("on-close")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiAlertDialog :open="modalState">
|
||||
<UiAlertDialogContent
|
||||
:disable-outside-pointer-events="true"
|
||||
:trap-focus="true"
|
||||
class="min-w-96"
|
||||
@escape-key-down="handleClosing"
|
||||
@focus-outside="handleClosing"
|
||||
@interact-outside="handleClosing"
|
||||
@pointer-down-outside="handleClosing"
|
||||
>
|
||||
<UiAlertDialogTitle>
|
||||
{{ $t('entity.world.deleteDialog.title') }}
|
||||
</UiAlertDialogTitle>
|
||||
|
||||
<UiAlertDialogDescription>
|
||||
{{ $t('entity.world.deleteDialog.subtitle') }}
|
||||
</UiAlertDialogDescription>
|
||||
|
||||
<form @submit.prevent="handleAction">
|
||||
<div class="grid grid-cols-2 gap-y-4">
|
||||
<div class="text-red-500 ml-8">
|
||||
<span class="text-sm">
|
||||
<!-- {{ formErrors.message }} -->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="flex gap-2 justify-between">
|
||||
<UiButton type="button" size="sm" variant="outline" @click="handleClosing">
|
||||
{{ $t('ui.action.back') }}
|
||||
</UiButton>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Transition name="fade-delay">
|
||||
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
</Transition>
|
||||
|
||||
<UiButton v-if="world" size="sm" variant="destructive" :disabled="isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('entity.deleteOne', { entity: world?.name }) }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</UiAlertDialogContent>
|
||||
</UiAlertDialog>
|
||||
</template>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCircleNotch } from "@phosphor-icons/vue";
|
||||
import type { World } from "~/models/World";
|
||||
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const defaultWorld: World = { name: "", description: "", gmId: user.value?.id }
|
||||
const worldSkeleton = ref<World>({ ...defaultWorld })
|
||||
|
||||
onMounted(() => {
|
||||
worldSkeleton.value = { ...defaultWorld }
|
||||
})
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* === Form Validation ===
|
||||
*/
|
||||
/** Whether the skeleton has a valid name */
|
||||
const validSkeleton = computed(() => worldSkeleton.value.name)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!user.value) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
await $fetch("/api/worlds/create", { method: "POST", body: { ...worldSkeleton.value } })
|
||||
|
||||
emit("on-close")
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* === Watch for name changes to display above ===
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(e: "on-changed-name", calendarName: string): void
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
(e: "on-close"): void
|
||||
}>()
|
||||
|
||||
/** Hook to emit a debounced event for the changed skeleton name */
|
||||
const handleNameChange = useDebounceFn(() => {
|
||||
emit("on-changed-name", worldSkeleton.value.name)
|
||||
}, 400)
|
||||
|
||||
function handleFormCancel() {
|
||||
emit("on-close")
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="worldSkeleton">
|
||||
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<input
|
||||
id="new-world-name"
|
||||
v-model="worldSkeleton.name"
|
||||
type="text"
|
||||
name="new-world-name"
|
||||
required
|
||||
:placeholder="$t('common.title')"
|
||||
class="w-full -my-1 mb-4 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
|
||||
@input="handleNameChange"
|
||||
>
|
||||
|
||||
<div class="-mx-1 mb-4">
|
||||
<InputColor v-model="worldSkeleton.color" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="new-world-description"
|
||||
v-model="worldSkeleton.description"
|
||||
name="new-world-description"
|
||||
:placeholder="$t('entity.addDescription')"
|
||||
class="w-full -my-1 py-1 -mx-1 px-1 min-h-24 max-h-36 text-sm border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end gap-2 mt-6">
|
||||
<UiButton type="button" variant="destructive" @click="handleFormCancel">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
|
||||
<UiButton type="submit" :disabled="!validSkeleton || isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('ui.action.save') }}
|
||||
</UiButton>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
15
cypress.config.ts
Normal file
15
cypress.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
|
||||
baseUrl: 'http://localhost:4173'
|
||||
},
|
||||
component: {
|
||||
specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
|
||||
devServer: {
|
||||
framework: 'vue',
|
||||
bundler: 'vite'
|
||||
}
|
||||
}
|
||||
})
|
||||
8
cypress/e2e/example.cy.ts
Normal file
8
cypress/e2e/example.cy.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// https://on.cypress.io/api
|
||||
|
||||
describe('My First Test', () => {
|
||||
it('visits the app root url', () => {
|
||||
cy.visit('/')
|
||||
cy.contains('h1', 'You did it!')
|
||||
})
|
||||
})
|
||||
10
cypress/e2e/tsconfig.json
Normal file
10
cypress/e2e/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["./**/*", "../support/**/*"],
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress"]
|
||||
}
|
||||
}
|
||||
5
cypress/fixtures/example.json
Normal file
5
cypress/fixtures/example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user