117 Commits

Author SHA1 Message Date
Alexis
2faefa32ef Fixed plural typo 2025-04-16 10:55:41 +02:00
Alexis
d681c45a1e Fixed deleted toast message 2025-04-16 10:53:55 +02:00
Alexis
db6ae36743 Fixed short id calendar page data 2025-04-16 10:50:39 +02:00
Alexis
6abf89e8db Moved categories to the same scope as calendar events 2025-04-16 10:49:11 +02:00
Alexis
f1abae1cce Fixed client side error 2025-04-15 21:09:02 +02:00
Alexis
a50824bd4f Cleaned up more console logs 2025-04-15 20:34:01 +02:00
Alexis
ed1252add4 Cleanup console logs 2025-04-15 20:33:41 +02:00
Alexis
1e0d840b41 Added category deletion interface 2025-04-15 20:32:03 +02:00
Alexis
58f3783b13 Added category creation interface 2025-04-15 15:34:05 +02:00
Alexis
c25c10f7b7 Fixed categories not updating 2025-04-15 11:18:26 +02:00
Alexis
f00e049f76 Added toast messages 2025-04-06 16:18:09 +02:00
Alexis
02fba36421 Add watch function if categories change 2025-04-06 16:08:45 +02:00
Alexis
aa76b17c37 Changed lazy fetch for reactive store data 2025-04-06 15:45:26 +02:00
Alexis
b32fab81f6 Added category update 2025-04-06 14:41:46 +02:00
Alexis
8005a8e9a2 Added read only category table 2025-03-31 19:10:11 +02:00
Alexis
d4d74db5d9 Refactored some stuff 2025-03-30 16:48:30 +02:00
Alexis
637a5cd7e3 Added sample behaviour for category modal 2025-03-28 20:54:12 +01:00
Alexis
2856185a3c Display category menu cta only if not read only 2025-03-28 15:34:30 +01:00
Alexis
d3a1ff92e2 Added option menu for calendar 2025-03-28 15:23:38 +01:00
Alexis
d2295024bd Changed span to kbd 2025-03-28 12:12:37 +01:00
Alexis
149d0794e3 Moved navigation to sub nav 2025-03-28 12:11:54 +01:00
Alexis
7f3a2b8d56 Fixed long RLS name 2025-03-15 14:52:26 +01:00
Alexis
6992bca957 Changed hidden translations 2025-03-14 22:30:10 +01:00
Alexis
f3fd97e3cd Changed categories to be linked to calendars and worlds 2025-03-14 22:25:50 +01:00
Alexis
e6b82e9b85 Merge branch 'code/cleanup' into dev 2025-03-14 18:21:56 +01:00
Alexis
15458ab5fd Fixed col size for world / calendar 2025-03-14 18:17:04 +01:00
Alexis
6add25f45f Fixed grid lines for calendars 2025-03-14 18:10:45 +01:00
Alexis
1bee00ffd6 Minimized sidebar bg 2025-03-14 18:03:48 +01:00
Alexis
404174de31 Merge branch 'code/cleanup' into dev 2025-03-14 17:45:15 +01:00
Alexis
5b38b3d0fa Added loading card component 2025-03-14 17:45:09 +01:00
Alexis
586adee6a9 Fixed calendar spacing without breadcrumbs 2025-03-14 16:56:53 +01:00
Alexis
f94c688baa Accessible event button styles 2025-03-14 16:47:43 +01:00
Alexis
9034406a45 Set initial focus for event search 2025-03-14 15:49:36 +01:00
Alexis
9e814e319f Set initial focus event creation 2025-03-14 15:38:19 +01:00
AlexisNP
a1dc761904 Merge pull request #71 from AlexisNP/features/revamp-sidebar
Features/revamp sidebar
2025-03-14 12:17:55 +01:00
Alexis
c3c4554e09 Fixed calendar grid colours 2025-03-14 12:15:36 +01:00
Alexis
c249dc8334 Fixed regression on new event popover closing
The setTimeout isn't good for reseting the skeleton ; it basically overrides the next time we open another day to add an event
2025-03-14 12:10:46 +01:00
Alexis
895d8a887b Removed sidebar and added breadcrumbs 2025-03-14 12:07:03 +01:00
Alexis
0126d42762 Fixed data loading timing issue 2025-03-13 22:11:19 +01:00
Alexis
1de5f13df6 Added reset menu to explorer 2025-03-13 18:25:27 +01:00
Alexis
f0e3fc2167 Changed the way the sidebar works 2025-03-13 18:25:04 +01:00
Alexis
fdb77a20f3 Added catch layout for error pages 2025-03-13 12:25:34 +01:00
Alexis
2cdf793e67 Removed unused setting link 2025-03-12 21:05:20 +01:00
AlexisNP
d7b299e0eb Merge pull request #70 from AlexisNP/bugfix/events-date
Bugfix/events date
2025-03-11 13:41:35 +01:00
Alexis
04ecbe1c2b Fixed more clone issues 2025-03-11 13:33:44 +01:00
Alexis
267ed70f5a Fixed reactive clone issues 2025-03-11 13:32:18 +01:00
AlexisNP
2716e6d559 Merge pull request #69 from AlexisNP/features/draft-published-state
Features/draft published state
2025-03-10 21:58:21 +01:00
Alexis
5664204fce Applied color to preview calendar card 2025-03-10 21:50:43 +01:00
Alexis
e0d5f679b0 Added client side realtime for calendars 2025-03-10 21:42:04 +01:00
Alexis
ae8937e7a6 Added update channel to calendar 2025-03-07 21:36:10 +01:00
Alexis
655f5d20f3 Added state on calendar preview 2025-03-07 18:56:21 +01:00
Alexis
965452671b Fixed RLS issues for calendars 2025-03-07 18:35:14 +01:00
Alexis
31a4316186 Commit stuff in case my computer explodes 2025-03-06 20:34:59 +01:00
Alexis
d46da12745 Added fix for calendar update channel 2025-03-05 20:21:33 +01:00
Alexis
1aa3350da3 Fixed a bug with dates and some style issues 2025-03-05 20:03:52 +01:00
Alexis
8848fa75b1 Fixed RLS and added visual feedback on dashboard 2025-03-05 16:34:53 +01:00
Alexis
282aabb7fa Added state selector for world 2025-03-04 19:02:27 +01:00
Alexis
204de011f6 Merge branch 'code/cleanup' into dev 2025-03-04 18:30:06 +01:00
Alexis
d73fe93812 Fixed event skeleton not being reset on edit close 2025-03-04 14:15:57 +01:00
Alexis
5df57ee587 Merge branch 'code/cleanup' into dev 2025-03-04 13:59:02 +01:00
Alexis
3f4cb46ebf Added timestamps for calendars 2025-03-03 23:13:13 +01:00
Alexis
922bcdc64b Added a bunch of mini features
Sorted worlds, add preview and add cards, added timestamps...
2025-03-03 22:27:42 +01:00
Alexis
a83bc240b1 Added ui card to add worlds / calendars 2025-03-03 12:35:59 +01:00
Alexis
7e2989e383 Polished world modal behaviour 2025-03-02 20:02:51 +01:00
Alexis
71866b0e4b Fixed focus issues on modals closing 2025-03-01 14:37:47 +01:00
Alexis
ba3eb29e04 Added world edit subscription
And also toast lifetimes
2025-03-01 14:14:55 +01:00
Alexis
189f0eec75 Fixed readOnly status for calendar 2025-03-01 13:19:02 +01:00
AlexisNP
4dbd80fa80 Merge pull request #64 from AlexisNP/features/hidden-events-revamp
Features/hidden events revamp
2025-02-28 23:11:21 +01:00
Alexis
b2ec583a09 Fixed style for hiddent white events 2025-02-28 23:08:16 +01:00
Alexis
d663253a05 Changed style of event callouts 2025-02-28 23:04:26 +01:00
Alexis
0cc6d0f1ee Changed style of event details button 2025-02-28 22:55:39 +01:00
Alexis
9cc0eabe3c Made user cta button more visible 2025-02-28 22:27:47 +01:00
Alexis
2e63836b80 Merge branch 'code/cleanup' into dev 2025-02-28 21:55:57 +01:00
Alexis
fe035da7c3 Added edit world functions 2025-02-28 19:24:53 +01:00
Alexis
04940aacc3 Formatted data calls and removed svg bg 2025-02-28 17:25:03 +01:00
Alexis
d04cee2d12 Fixed 404 pages 2025-02-28 17:14:42 +01:00
Alexis
879ff732ef Added sidebar bg image 2025-02-28 16:20:52 +01:00
Alexis
500941ae69 Revamped world slug code 2025-02-28 14:34:57 +01:00
Alexis
d6d173d087 Fixed loading syntax for calendar 2025-02-28 13:42:09 +01:00
Alexis
c59d4eaf92 Added compatibility date ? 2025-02-27 22:28:08 +01:00
Alexis
60755fd46f Merge branch 'update' into dev 2025-02-27 22:25:35 +01:00
Alexis
8eecad6488 Fixed calendar event issues ? 2025-02-27 22:25:28 +01:00
Alexis
8ff5d5941a Updated major versions 2025-02-14 16:55:47 +01:00
Alexis
fe9a58b0ea Updated minor versions 2025-02-14 16:44:45 +01:00
Alexis
dcb637021e Hotfix : Added RLS for world 2024-12-20 17:44:54 +01:00
AlexisNP
5689d2689d Merge pull request #60 from AlexisNP/bugfix/quick-create-event-modal
Bugfix/quick create event modal
2024-12-20 11:49:14 +01:00
Alexis
91048d1b8e Watched for user change in the page instead 2024-12-20 11:41:17 +01:00
Alexis
3db1654682 Removed console log 2024-12-20 11:31:40 +01:00
Alexis
9f6d318a15 Removed duplicate closing calls for create event popover 2024-12-20 11:29:13 +01:00
Alexis
eedbf1decb Removed user watcher for now 2024-12-20 11:16:41 +01:00
Alexis
bc6cbd839f Added event count for calendars 2024-12-19 20:18:41 +01:00
Alexis
bf8257e60f Added explorer page 2024-12-19 19:38:57 +01:00
Alexis
199cfb6634 Added not found page for calendars 2024-12-19 16:46:22 +01:00
Alexis
df8af541c1 Added updated toast 2024-12-19 16:28:38 +01:00
Alexis
dde3f8504a Fixed updating modal not closing as well 2024-12-19 16:16:45 +01:00
AlexisNP
01fe9f3281 Merge pull request #59 from AlexisNP/bugfix/modal-issues 2024-12-19 15:55:52 +01:00
Alexis
bc7a400826 Fixed quick create event modal not closing on submit
This was caused by a missing event handler
2024-12-19 14:03:20 +01:00
Alexis
c99059d9a8 Fixed event popover not closing on submit
This was caused by a random stray isLoading
2024-12-19 14:01:05 +01:00
Alexis
cd965f61e8 Fixed event creation popover appearing for anon users 2024-12-19 13:52:15 +01:00
AlexisNP
9d5ff6a0b8 Merge pull request #57 from AlexisNP/features/calendar-public-links
Fixed calendar having null as visibility
2024-12-19 09:21:07 +01:00
Alexis
06fccd83ca Fixed calendar having null as visibility 2024-12-19 09:13:42 +01:00
AlexisNP
5f8ae1fb6c Merge pull request #56 from AlexisNP/features/calendar-public-links
Features/calendar public links
2024-12-18 20:17:43 +01:00
Alexis
535a73162c Added user change validation and read status
This might be better to just refetch the data with anonymous authorizations
2024-12-17 17:17:13 +01:00
Alexis
49574d8ef1 Added first draft of story 2024-12-09 23:58:16 +01:00
AlexisNP
323eedec56 Merge pull request #54 from AlexisNP/features/add-create-event-button
Features/add create event button
2024-12-03 15:31:54 +01:00
Alexis
4ddbfd9855 Changed organization for 2 mixed components 2024-12-03 15:29:06 +01:00
Alexis
bb6df1d440 Fixed organization 2024-12-03 15:21:36 +01:00
Alexis
dd1cb1f420 Add quick create event button 2024-12-03 14:53:24 +01:00
AlexisNP
11252c168e Merge pull request #50 from AlexisNP/bugfix/fix-leftover-hardcoded-logic
Bugfix/fix leftover hardcoded logic
2024-12-02 18:34:55 +01:00
Alexis
acd0476970 Removed paged component nav
As this will be moved to their own files and on the server side, I see little reason to refactor them, just so they'll be refactored again down the line.
2024-12-02 18:31:08 +01:00
Alexis
24c048df4d Fixed monthsPerYear leftover data 2024-11-27 19:36:37 +01:00
AlexisNP
c55d690f8b Merge pull request #49 from AlexisNP/bugfix/event-details-loses-focus-twice
Added prevent on focus outside event for event details
2024-11-27 17:18:11 +01:00
Alexis
39e4ac33b7 Added prevent on focus outside event for event details 2024-11-27 17:16:10 +01:00
AlexisNP
568efa2251 Merge pull request #48 from AlexisNP/features/nav-should-toast
Added toast when navigating out of bounds of event pages
2024-11-27 14:10:57 +01:00
Alexis
6a68105a0f Added toast when navigating out of bounds of event pages 2024-11-27 14:07:18 +01:00
AlexisNP
bba01fdcf1 Merge pull request #47 from AlexisNP/bugfix/one-event-fills-the-stack
Removed overkill pattern
2024-11-26 20:03:30 +01:00
Alexis
76a6468a04 Removed overkill patterns
This fixes the stack being trashed up
2024-11-26 19:59:18 +01:00
125 changed files with 8937 additions and 7376 deletions

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.12.0

View File

@@ -22,7 +22,7 @@
--primary: 245 58% 51%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary: 210 50% 95%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
@@ -86,62 +86,124 @@
}
/* Colors */
.event-red {
@apply text-slate-900 bg-red-500 hover:bg-red-600
.event-red:not(.is-hidden) {
@apply text-slate-900 bg-red-500 hover:bg-red-600 focus-visible:bg-red-600
}
.event-orange {
@apply text-slate-900 bg-orange-500 hover:bg-orange-600
.event-orange:not(.is-hidden) {
@apply text-slate-900 bg-orange-500 hover:bg-orange-600 focus-visible:bg-orange-600
}
.event-amber {
@apply text-slate-900 bg-amber-500 hover:bg-amber-600
.event-amber:not(.is-hidden) {
@apply text-slate-900 bg-amber-500 hover:bg-amber-600 focus-visible:bg-amber-600
}
.event-yellow {
@apply text-slate-900 bg-yellow-600 hover:bg-yellow-700
.event-yellow:not(.is-hidden) {
@apply text-slate-900 bg-yellow-600 hover:bg-yellow-700 focus-visible:bg-yellow-700
}
.event-lime {
@apply text-slate-900 bg-lime-500 hover:bg-lime-600
.event-lime:not(.is-hidden) {
@apply text-slate-900 bg-lime-500 hover:bg-lime-600 focus-visible:bg-lime-600
}
.event-green {
@apply text-white bg-green-600 hover:bg-green-700
.event-green:not(.is-hidden) {
@apply text-white bg-green-600 hover:bg-green-700 focus-visible:bg-green-700
}
.event-emerald {
@apply text-white bg-emerald-600 hover:bg-emerald-700
.event-emerald:not(.is-hidden) {
@apply text-white bg-emerald-600 hover:bg-emerald-700 focus-visible:bg-emerald-700
}
.event-teal {
@apply text-white bg-teal-600 hover:bg-teal-700
.event-teal:not(.is-hidden) {
@apply text-white bg-teal-600 hover:bg-teal-700 focus-visible:bg-teal-700
}
.event-cyan {
@apply text-white bg-cyan-600 hover:bg-cyan-700
.event-cyan:not(.is-hidden) {
@apply text-white bg-cyan-600 hover:bg-cyan-700 focus-visible:bg-cyan-700
}
.event-sky {
@apply text-white bg-sky-600 hover:bg-sky-700
.event-sky:not(.is-hidden) {
@apply text-white bg-sky-600 hover:bg-sky-700 focus-visible:bg-sky-700
}
.event-blue {
@apply text-white bg-blue-600 hover:bg-blue-700
.event-blue:not(.is-hidden) {
@apply text-white bg-blue-600 hover:bg-blue-700 focus-visible:bg-blue-700
}
.event-indigo {
@apply text-white bg-indigo-600 hover:bg-indigo-700
.event-indigo:not(.is-hidden) {
@apply text-white bg-indigo-600 hover:bg-indigo-700 focus-visible:bg-indigo-700
}
.event-violet {
@apply text-white bg-violet-600 hover:bg-violet-700
.event-violet:not(.is-hidden) {
@apply text-white bg-violet-600 hover:bg-violet-700 focus-visible:bg-violet-700
}
.event-purple {
@apply text-white bg-purple-600 hover:bg-purple-700
.event-purple:not(.is-hidden) {
@apply text-white bg-purple-600 hover:bg-purple-700 focus-visible:bg-purple-700
}
.event-fuchsia {
@apply text-white bg-fuchsia-600 hover:bg-fuchsia-700
.event-fuchsia:not(.is-hidden) {
@apply text-white bg-fuchsia-600 hover:bg-fuchsia-700 focus-visible:bg-fuchsia-700
}
.event-pink {
@apply text-white bg-pink-700 hover:bg-pink-800
.event-pink:not(.is-hidden) {
@apply text-white bg-pink-700 hover:bg-pink-800 focus-visible:bg-pink-800
}
.event-rose {
@apply text-white bg-rose-700 hover:bg-rose-800
.event-rose:not(.is-hidden) {
@apply text-white bg-rose-700 hover:bg-rose-800 focus-visible:bg-rose-800
}
.event-black {
@apply text-white bg-stone-500 hover:bg-stone-700
.event-black:not(.is-hidden) {
@apply text-white bg-stone-500 hover:bg-stone-700 focus-visible:bg-stone-700
}
.event-white {
@apply text-slate-900 bg-white hover:bg-yellow-200 border-[1px] border-slate-300
.event-white:not(.is-hidden) {
@apply text-slate-900 bg-white hover:bg-yellow-200 focus-visible:bg-yellow-200 border-[1px] border-slate-300
}
.event.is-hidden {
border: 1px dashed transparent;
}
.event-red.is-hidden {
@apply text-red-600 border-red-600 hover:bg-red-100 focus-visible:bg-red-100 dark:text-white dark:hover:bg-red-950 dark:focus-visible:bg-red-950
}
.event-orange.is-hidden {
@apply text-orange-600 border-orange-600 hover:bg-orange-100 focus-visible:bg-orange-100 dark:text-white dark:hover:bg-orange-950 dark:focus-visible:bg-orange-950
}
.event-amber.is-hidden {
@apply text-red-600 border-red-600 hover:bg-red-100 focus-visible:bg-red-100 dark:text-white dark:hover:bg-red-950 dark:focus-visible:bg-red-950
}
.event-yellow.is-hidden {
@apply text-yellow-600 border-yellow-600 hover:bg-yellow-100 focus-visible:bg-yellow-100 dark:text-white dark:hover:bg-yellow-950 dark:focus-visible:bg-yellow-950
}
.event-lime.is-hidden {
@apply text-lime-600 border-lime-600 hover:bg-lime-100 focus-visible:bg-lime-100 dark:text-white dark:hover:bg-lime-950 dark:focus-visible:bg-lime-950
}
.event-green.is-hidden {
@apply text-green-600 border-green-600 hover:bg-green-700 focus-visible:bg-green-700 dark:text-white dark:hover:bg-green-950 dark:focus-visible:bg-green-950
}
.event-emerald.is-hidden {
@apply text-emerald-600 border-emerald-600 hover:bg-emerald-700 focus-visible:bg-emerald-700 dark:text-white dark:hover:bg-emerald-950 dark:focus-visible:bg-emerald-950
}
.event-teal.is-hidden {
@apply text-teal-700 border-teal-600 hover:bg-teal-700 focus-visible:bg-teal-700 dark:text-white dark:hover:bg-teal-950 dark:focus-visible:bg-teal-950
}
.event-cyan.is-hidden {
@apply text-cyan-600 border-cyan-600 hover:bg-cyan-100 focus-visible:bg-cyan-100 dark:text-white dark:hover:bg-cyan-950 dark:focus-visible:bg-cyan-950
}
.event-sky.is-hidden {
@apply text-sky-600 border-sky-600 hover:bg-sky-100 focus-visible:bg-sky-100 dark:text-white dark:hover:bg-sky-950 dark:focus-visible:bg-sky-950
}
.event-blue.is-hidden {
@apply text-blue-600 border-blue-600 hover:bg-blue-100 focus-visible:bg-blue-100 dark:text-white dark:hover:bg-blue-950 dark:focus-visible:bg-blue-950
}
.event-indigo.is-hidden {
@apply text-indigo-600 border-indigo-600 hover:bg-indigo-100 focus-visible:bg-indigo-100 dark:text-white dark:hover:bg-indigo-950 dark:focus-visible:bg-indigo-950
}
.event-violet.is-hidden {
@apply text-violet-600 border-violet-600 hover:bg-violet-100 focus-visible:bg-violet-100 dark:text-white dark:hover:bg-violet-950 dark:focus-visible:bg-violet-950
}
.event-purple.is-hidden {
@apply text-purple-600 border-purple-600 hover:bg-purple-100 focus-visible:bg-purple-100 dark:text-white dark:hover:bg-purple-950 dark:focus-visible:bg-purple-950
}
.event-fuchsia.is-hidden {
@apply text-fuchsia-600 border-fuchsia-600 hover:bg-fuchsia-100 focus-visible:bg-fuchsia-100 dark:text-white dark:hover:bg-fuchsia-950 dark:focus-visible:bg-fuchsia-950
}
.event-pink.is-hidden {
@apply text-pink-700 border-pink-700 hover:bg-pink-100 focus-visible:bg-pink-100 dark:text-white dark:hover:bg-pink-950 dark:focus-visible:bg-pink-950
}
.event-rose.is-hidden {
@apply text-rose-700 border-rose-700 hover:bg-rose-100 focus-visible:bg-rose-100 dark:text-white dark:hover:bg-rose-950 dark:focus-visible:bg-rose-950
}
.event-black.is-hidden {
@apply text-stone-700 border-stone-500 hover:bg-stone-100 focus-visible:bg-stone-100 dark:text-white dark:hover:bg-stone-950 dark:focus-visible:bg-stone-950
}
.event-white.is-hidden {
@apply text-slate-900 border-neutral-400 hover:bg-yellow-200 focus-visible:bg-yellow-200 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-700 dark:focus-visible:bg-neutral-300
}
.dark .event-details-red {
@@ -276,6 +338,81 @@
--border-color: color-mix(in srgb, var(--base-color), var(--color-slate-800) 50%);
}
.bgc {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.bgc::before {
content: "";
display: inline-block;
width: .75rem;
aspect-ratio: 1;
margin-right: 0.5em;
border-radius: .25rem;
background-color: red;
border: 1px solid transparent;
}
.bgc-red::before {
@apply bg-red-500;
}
.bgc-orange::before {
@apply bg-orange-500;
}
.bgc-amber::before {
@apply bg-amber-500;
}
.bgc-yellow::before {
@apply bg-yellow-500;
}
.bgc-lime::before {
@apply bg-lime-500;
}
.bgc-green::before {
@apply bg-green-500;
}
.bgc-emerald::before {
@apply bg-emerald-600;
}
.bgc-teal::before {
@apply bg-teal-600;
}
.bgc-cyan::before {
@apply bg-cyan-600;
}
.bgc-sky::before {
@apply bg-sky-600;
}
.bgc-blue::before {
@apply bg-blue-600;
}
.bgc-indigo::before {
@apply bg-indigo-600;
}
.bgc-violet::before {
@apply bg-violet-600;
}
.bgc-purple::before {
@apply bg-purple-600;
}
.bgc-fuchsia::before {
@apply bg-fuchsia-600;
}
.bgc-pink::before {
@apply bg-pink-600;
}
.bgc-rose::before {
@apply bg-rose-600;
}
.bgc-black::before {
@apply bg-black dark:border-[1px] dark:border-slate-300;
}
.bgc-white::before {
@apply bg-white border-[1px] border-slate-700;
}
.fade-enter-active,
.fade-leave-active {
transition: all .5s ease;

View File

@@ -8,20 +8,8 @@ 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 { isReadOnly } = storeToRefs(useCalendar())
const currentViewComponent: ComputedRef<Component> = computed<Component>(() => {
switch (currentConfig.viewType) {
@@ -52,9 +40,10 @@ onMounted(() => {
<component :is="currentViewComponent" />
<CalendarSearch />
<CalendarFormUpdateEvent />
<CalendarFormDeleteEvent />
<LazyCalendarSearch />
<LazyCalendarDialogCategories v-if="!isReadOnly" />
<LazyCalendarDialogUpdateEvent v-if="!isReadOnly" />
<LazyCalendarDialogDeleteEvent v-if="!isReadOnly" />
</div>
</div>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { PhTag } from "@phosphor-icons/vue"
const { toggleCategoriesModal } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
</script>
<template>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton v-if="!isReadOnly" variant="secondary" size="icon" @click="toggleCategoriesModal(true)">
<PhTag size="20" weight="light" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.seeCategories') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</template>

View File

@@ -4,26 +4,29 @@ import { computed } from "vue"
import { PhMapPin } from "@phosphor-icons/vue"
const { defaultDate, getFormattedDateTitle, getRelativeString } = useCalendar()
const { defaultDate, getFormattedDateTitle, getRelativeString, getDifferenceInDays } = 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))
const isToday = computed(() => getDifferenceInDays(defaultDate, selectedDate.value) === 0)
</script>
<template>
<ClientOnly>
<div class="flex gap-2 items-center">
<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 v-if="!isToday" class="text-xl italic opacity-75">
{{ dateDifference }}
</h2>
</div>
<template #fallback>
<div class="grid gap-1">
<div class="flex items-center gap-1">
<UiSkeleton class="h-8 w-64" />
<UiSkeleton class="h-6 w-28" />
</div>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { cn } from "@/lib/utils"
import { useCalendar } from "@/stores/CalendarStore"
import { PhCalendarBlank, PhCheckCircle, PhGear } 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 variant="secondary" size="icon">
<PhGear size="20" weight="fill" />
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent :side="'bottom'" :align="'start'" :side-offset="10" :align-offset="25" :collision-padding="40" class="text-right">
<UiDropdownMenuArrow />
<UiDropdownMenuLabel>
{{ $t('entity.calendar.seeOptions') }}
</UiDropdownMenuLabel>
<UiDropdownMenuSub>
<UiDropdownMenuSubTrigger arrow-direction="left" class="p-0 rounded-none">
<UiDropdownMenuItem class="flex gap-[1ch] justify-end items-center pointer-events-none">
{{ $t('ui.displayMode') }}
<PhCalendarBlank size="18" weight="fill" />
</UiDropdownMenuItem>
</UiDropdownMenuSubTrigger>
<UiDropdownMenuPortal>
<UiDropdownMenuSubContent>
<UiDropdownMenuItem
v-for="option in viewTypeOptions"
:key="option"
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': viewTypeTitle === getViewTypeTitle(option) })"
@click="setViewType(option)"
>
<PhCheckCircle v-if="viewTypeTitle === getViewTypeTitle(option)" size="20" weight="fill" />
{{ getViewTypeTitle(option) }}
</UiDropdownMenuItem>
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import { PhArchive, PhCalendarDots, PhFile, PhFileDashed, PhFilePlus, PhPencil, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue";
import { DateTime } from "luxon";
import type { Calendar } from "~/models/CalendarConfig";
const props = defineProps<{
calendar: Calendar,
gmId?: string,
showActions?: boolean,
}>()
const emit = defineEmits(["on-edit", "on-delete"])
const { locale } = useI18n();
const createdAt = DateTime.fromISO(props.calendar.createdAt!).toLocaleString(DateTime.DATETIME_MED, { locale: locale.value });
const updatedAt = computed<string>(() => props.calendar.updatedAt ? DateTime.fromISO(props.calendar.updatedAt).toLocaleString(DateTime.DATETIME_MED, { locale: locale.value }) : "");
const user = useSupabaseUser();
const isOwner = computed(() => user.value && props.gmId && user.value.id === props.gmId);
const calendarLink = computed(() => isOwner.value ? `/my/calendars/${props.calendar.id}` : `/calendars/${props.calendar.shortId}`);
</script>
<template>
<UiCard
class="w-full h-full flex flex-col transition-all hover:bg-slate-50 dark:bg-gray-950 dark:hover:bg-indigo-950 dark:focus-within:outline-gray-900"
:link="calendarLink"
:class="{
'hover:bg-slate-50 dark:hover:bg-sky-950 dark:focus-within:outline-sky-900': !calendar.color,
'bg-red-100 dark:bg-red-950 border-red-200 hover:bg-red-50 dark:hover:bg-red-900 dark:border-red-900 dark:focus-within:outline-red-900': calendar.color === 'red',
'bg-orange-100 dark:bg-orange-950 border-orange-200 hover:bg-orange-50 dark:hover:bg-orange-900 dark:border-orange-900 dark:focus-within:outline-orange-900': calendar.color === 'orange',
'bg-amber-100 dark:bg-amber-950 border-amber-200 hover:bg-amber-50 dark:hover:bg-amber-900 dark:border-amber-900 dark:focus-within:outline-amber-900': calendar.color === 'amber',
'bg-yellow-100 dark:bg-yellow-950 border-yellow-200 hover:bg-yellow-50 dark:hover:bg-yellow-900 dark:border-yellow-900 dark:focus-within:outline-yellow-900': calendar.color === 'yellow',
'bg-lime-100 dark:bg-lime-950 border-lime-200 hover:bg-lime-50 dark:hover:bg-lime-900 dark:border-lime-900 dark:focus-within:outline-lime-900': calendar.color === 'lime',
' bg-green-100 dark:bg-green-950 border-green-200 hover:bg-green-50 dark:hover:bg-green-900 dark:border-green-900 dark:focus-within:outline-green-900': calendar.color === 'green',
'bg-emerald-100 dark:bg-emerald-950 border-emerald-200 hover:bg-emerald-50 dark:hover:bg-emerald-900 dark:border-emerald-900 dark:focus-within:outline-emerald-900': calendar.color === 'emerald',
'bg-teal-100 dark:bg-teal-950 border-teal-200 hover:bg-teal-50 dark:hover:bg-teal-900 dark:border-teal-900 dark:focus-within:outline-teal-900': calendar.color === 'teal',
'bg-cyan-100 dark:bg-cyan-950 border-cyan-200 hover:bg-cyan-50 dark:hover:bg-cyan-900 dark:border-cyan-900 dark:focus-within:outline-cyan-900': calendar.color === 'cyan',
'bg-sky-100 dark:bg-sky-950 border-sky-200 hover:bg-sky-50 dark:hover:bg-sky-900 dark:border-sky-900 dark:focus-within:outline-sky-900': calendar.color === 'sky',
'bg-blue-100 dark:bg-blue-950 border-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900 dark:border-blue-900 dark:focus-within:outline-blue-900': calendar.color === 'blue',
'bg-indigo-100 dark:bg-indigo-950 border-indigo-200 hover:bg-indigo-50 dark:hover:bg-indigo-900 dark:border-indigo-900 dark:focus-within:outline-indigo-900': calendar.color === 'indigo',
'bg-violet-100 dark:bg-violet-950 border-violet-200 hover:bg-violet-50 dark:hover:bg-violet-900 dark:border-violet-900 dark:focus-within:outline-violet-900': calendar.color === 'violet',
'bg-purple-100 dark:bg-purple-950 border-purple-200 hover:bg-purple-50 dark:hover:bg-purple-900 dark:border-purple-900 dark:focus-within:outline-purple-900': calendar.color === 'purple',
'bg-fuchsia-100 dark:bg-fuchsia-950 border-fuchsia-200 hover:bg-fuchsia-50 dark:hover:bg-fuchsia-900 dark:border-fuchsia-900 dark:focus-within:outline-fuchsia-900': calendar.color === 'fuchsia',
'bg-pink-100 dark:bg-pink-950 border-pink-200 hover:bg-pink-50 dark:hover:bg-pink-900 dark:border-pink-900 dark:focus-within:outline-pink-900': calendar.color === 'pink',
'bg-pink-100 dark:bg-rose-950 border-rose-200 hover:bg-rose-50 dark:hover:bg-rose-900 dark:border-rose-900 dark:focus-within:outline-rose-900': calendar.color === 'rose',
'text-slate-100 bg-slate-900 border-slate-700 hover:bg-slate-700 dark:hover:bg-slate-800 dark:border-slate-900 dark:focus-within:outline-slate-900': calendar.color === 'black',
' hover:bg-slate-50 dark:hover:text-slate-900 dark:hover:bg-slate-200 dark:border-slate-700 dark:focus-within:outline-slate-100': calendar.color === 'white',
}"
>
<UiCardHeader class="gap-4">
<UiCardTitle class="text-xl pr-12">{{ calendar.name }}</UiCardTitle>
<div v-if="calendar.state === 'published'" class="flex items-center gap-1 text-sm">
<PhFile size="20" weight="fill" />
<span>{{ $t('ui.contentState.published') }}</span>
</div>
<div v-if="calendar.state === 'draft'" class="flex items-center gap-1 text-sm">
<PhFileDashed size="20" weight="fill" />
<span>{{ $t('ui.contentState.draft') }}</span>
</div>
<div v-if="calendar.state === 'archived'" class="flex items-center gap-1 text-sm">
<PhArchive size="20" weight="fill" />
<span>{{ $t('ui.contentState.archived') }}</span>
</div>
</UiCardHeader>
<UiCardContent class="grow">
<p class="flex items-center gap-1">
<PhCalendarDots size="24" weight="fill" />
<span v-if="calendar.eventNb?.[0].count! > 1">{{ $t("entity.calendar.hasXEvents", { count: calendar.eventNb?.[0].count }) }}</span>
<span v-else>{{ $t("entity.calendar.hasXEvent", { count: calendar.eventNb?.[0].count }) }}</span>
</p>
<div
v-if="isOwner && showActions"
class="flex gap-1 absolute top-4 right-4 z-20"
>
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-indigo-400 dark:hover:bg-indigo-700" @click="emit('on-edit')">
<PhPencil size="16" />
</UiButton>
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-rose-400 dark:hover:bg-rose-700" @click="emit('on-delete')">
<PhTrash size="16" />
</UiButton>
</div>
</UiCardContent>
<UiCardFooter>
<ul class="grid gap-1 text-sm">
<li class="flex gap-1 items-center">
<PhFilePlus size="18" />
<span>{{ $t('common.createdAt', { createdAt }) }}</span>
</li>
<li v-if="updatedAt" class="flex gap-1 items-center">
<PhPencilSimpleLine size="18" />
<span>{{ $t('common.updatedAt', { updatedAt }) }}</span>
</li>
</ul>
</UiCardFooter>
</UiCard>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Category } from "~/models/Category";
defineProps<{
categories: Category[]
}>()
</script>
<template>
<ul>
<li v-for="category in categories" :key="category.id">
<CalendarCategoryListItem :category="category" />
</li>
</ul>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { Category } from "~/models/Category";
defineProps<{
category: Category
}>()
</script>
<template>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ category.name }}
</span>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Category } from "~/models/Category";
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
const { t } = useI18n()
const { categories } = defineProps<{
categories: Category[]
}>()
const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.localeCompare(b.name)))
const deleteDialogOpened = ref<boolean>(false)
function openDeleteDialog() {
deleteDialogOpened.value = true
}
function closeDeleteDialog() {
deleteDialogOpened.value = false
}
</script>
<template>
<div class="flex flex-col h-full">
<CalendarCategoryTableHeader />
<ScrollAreaRoot class="h-36 grow overflow-hidden ">
<ScrollAreaViewport class="w-full h-full pr-4" as-child>
<div class="[&:last-child]:border-0">
<CalendarCategoryTableRow
v-for="item in sortedCategories"
:key="item.id"
:category="item"
@on-delete-category="openDeleteDialog"
/>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar
class="flex select-none touch-none p-0.5 bg-background transition-colors duration-100 ease-out hover:bg-foreground/5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:h-2.5"
orientation="vertical"
>
<ScrollAreaThumb
class="flex-1 bg-foreground/20 rounded-[10px] relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full"
/>
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<CalendarCategoryTableFooter />
</div>
<UiDialog v-model:open="deleteDialogOpened">
<UiDialogContent
: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="closeDeleteDialog"
@focus-outside="closeDeleteDialog"
@interact-outside="closeDeleteDialog"
@pointer-down-outside="closeDeleteDialog"
>
<UiDialogTitle>
{{ $t('entity.category.deleteDialog.title') }}
</UiDialogTitle>
<UiDialogDescription>
{{ $t('entity.category.deleteDialog.subtitle') }}
</UiDialogDescription>
<CalendarFormDeleteCategory />
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -0,0 +1,130 @@
<script lang="ts" setup>
import { PhPlus } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast"
import { ToastLifetime } from "~/components/ui/toast/use-toast"
import { cn } from "~/lib/utils"
const { toast } = useToast()
const { t } = useI18n()
type FooterMode = "add" | "view"
const currentMode = ref<FooterMode>("view")
const rowRef = ref<HTMLDivElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const { focused: inputFocused } = useFocus(inputRef)
/**
* Toggle view mode options
*/
type ToggleViewOptions = {
execution?: "now" | "nextTick"
}
/**
* Toggle view mode
* @param options.execution - When to execute the toggle. "now" or "nextTick"
*/
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
currentMode.value = "view"
if (options.execution === "now") {
inputFocused.value = false
} else {
nextTick(() => {
inputFocused.value = false
})
}
}
/**
* Toggle add mode
*/
function toggleAdd() {
currentMode.value = "add"
categorySkeleton.value = { name: "", color: "black" }
nextTick(() => {
inputFocused.value = true
})
}
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }))
onKeyStroke("Escape", () => toggleView({ execution: "now" }))
const { addCategoryFromSkeleton } = useCategoryStore()
const { categorySkeleton } = storeToRefs(useCategoryStore())
/**
* Submit the update
*/
async function submitNew() {
if (!categorySkeleton.value) return
const newCategoryName = toRaw(categorySkeleton.value).name
const { error } = await tryCatch(addCategoryFromSkeleton())
if (error) {
toast({
title: t("entity.category.addedToast.titleError", { category: newCategoryName }),
variant: "destructive",
duration: ToastLifetime.LONG
})
return
}
toggleView({ execution: "now" })
toast({
title: t("entity.category.addedToast.title", { category: newCategoryName }),
variant: "success",
duration: ToastLifetime.SHORT
})
}
</script>
<template>
<div class="border-y-[1px] border-b-foreground/10 mr-4">
<form
ref="rowRef"
class="grid grid-cols-12 items-center gap-4 p-1 bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900"
@submit.prevent="submitNew"
>
<div v-if="currentMode === 'add'" class="col-span-1" />
<div
:class="cn({
'col-span-6': currentMode === 'view',
'col-span-5': currentMode === 'add'
})"
>
<template v-if="currentMode === 'view'">
<button
class="p-2 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
@click="toggleAdd"
>
<PhPlus size="18" />
</button>
</template>
<template v-if="currentMode === 'add' && categorySkeleton">
<input
ref="inputRef"
v-model="categorySkeleton.name"
type="text"
class="p-1 h-full w-full bg-transparent focus-visible:outline-none italic"
>
</template>
</div>
<div class="col-span-4">
<template v-if="currentMode === 'add' && categorySkeleton">
<div class="-mx-2">
<InputColor
id="category-color"
v-model="categorySkeleton.color"
position="item-aligned"
theme="subtle"
/>
</div>
</template>
</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import { PhListBullets } from "@phosphor-icons/vue";
</script>
<template>
<header class="grid grid-cols-12 gap-4 px-2 py-4 border-b-[1px] border-b-foreground/10 font-bold mr-4">
<div class="col-span-1 flex items-end">
<PhListBullets size="20" />
</div>
<div class="col-span-5">
Nom
</div>
<div class="col-span-4">
Couleur
</div>
<div class="col-span-2" />
</header>
</template>

View File

@@ -0,0 +1,187 @@
<script setup lang="ts">
import { PhCheck, PhTrash } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast"
import { ToastLifetime } from "~/components/ui/toast/use-toast"
import { cn } from "~/lib/utils"
import type { Category } from "~/models/Category"
const { toast } = useToast()
const { t } = useI18n()
const { category } = defineProps<{
category: Category
}>()
const emit = defineEmits<{
(e: "on-delete-category", payload: Category): void
}>()
type RowMode = "edit" | "view"
const currentMode = ref<RowMode>("view")
const rowRef = ref<HTMLDivElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const { focused: inputFocused } = useFocus(inputRef)
const rowHovered = useElementHover(rowRef)
/**
* Toggle view mode options
*/
type ToggleViewOptions = {
execution?: "now" | "nextTick"
}
/**
* Toggle view mode
* @param options.execution - When to execute the toggle. "now" or "nextTick"
*/
function toggleView(options: ToggleViewOptions = { execution: "now" }) {
currentMode.value = "view"
if (options.execution === "now") {
inputFocused.value = false
} else {
nextTick(() => {
inputFocused.value = false
})
}
}
/**
* Toggle edit mode
*/
function toggleEdit() {
currentMode.value = "edit"
categorySkeleton.value = structuredClone(toRaw(category))
nextTick(() => {
inputFocused.value = true
})
}
onClickOutside(rowRef, () => toggleView({ execution: "nextTick" }))
onKeyStroke("Escape", () => toggleView({ execution: "now" }))
const { resetSkeleton, updateCategoryFromSkeleton } = useCategoryStore()
const { categorySkeleton } = storeToRefs(useCategoryStore())
onUnmounted(() => {
resetSkeleton()
})
/**
* Submit the update
*/
async function submitUpdate() {
const { error } = await tryCatch(updateCategoryFromSkeleton())
if (error) {
toast({
title: t("entity.category.updatedToast.titleError", { category: category.name }),
variant: "destructive",
duration: ToastLifetime.LONG
})
return
}
toggleView({ execution: "now" })
toast({
title: t("entity.category.updatedToast.title", { category: category.name }),
variant: "success",
duration: ToastLifetime.SHORT
})
}
function handleQueryDelete() {
categorySkeleton.value = structuredClone(toRaw(category))
emit("on-delete-category", category)
}
</script>
<template>
<div ref="rowRef" class="relative">
<form
class="grid grid-cols-12 items-center gap-4 p-1 border-b-[1px] border-b-foreground/10"
:class="cn(
{ 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-900 dark:hover:bg-slate-800': currentMode === 'edit' },
{ 'bg-transparent hover:bg-slate-50 dark:bg-transparent dark:hover:bg-slate-900': currentMode !== 'edit' }
)"
@submit.prevent="submitUpdate"
>
<div class="col-span-1 pointer-events-none">
<span class="opacity-50 p-2">{{ category.id }}</span>
</div>
<div
class="col-span-5"
>
<template v-if="currentMode === 'view'">
<button
class="py-2 px-1 h-full w-full text-left underline-offset-4 hover:underline cursor-pointer"
@click="toggleEdit"
>
{{ category.name }}
</button>
</template>
<template v-else-if="currentMode === 'edit' && categorySkeleton">
<input
ref="inputRef"
v-model="categorySkeleton.name"
type="text"
class="p-1 h-full w-full bg-transparent focus-visible:outline-none italic"
>
</template>
</div>
<div class="col-span-4">
<template v-if="currentMode === 'view'">
<button
class="p-1 h-full w-full text-left text-sm cursor-pointer"
@click="toggleEdit"
>
<span
class="bgc"
:class="cn(`bgc-${category.color}`)"
>
{{ $t(`ui.colors.${category.color}`) }}
</span>
</button>
</template>
<template v-else-if="currentMode === 'edit' && categorySkeleton">
<div class="-mx-2">
<InputColor
id="category-color"
v-model="categorySkeleton.color"
position="item-aligned"
theme="subtle"
/>
</div>
</template>
</div>
</form>
<menu class="w-fit absolute top-1/2 -translate-y-1/2 right-2 flex items-center gap-2">
<li
v-if="currentMode === 'edit' && categorySkeleton"
>
<UiButton
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white"
:title="$t('ui.actions.edit')"
@click="submitUpdate"
>
<PhCheck size="14" weight="bold" />
</UiButton>
</li>
<li v-else-if="rowHovered">
<UiButton
variant="secondary"
size="icon"
class="w-6 h-6 rounded-full hover:bg-red-600 hover:text-white"
:title="$t('ui.actions.delete')"
@click="handleQueryDelete"
>
<PhTrash size="14" weight="bold" />
</UiButton>
</li>
</menu>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
const { toggleCategoriesModal } = useCalendar()
const { categories, isCategoriesModalOpen } = storeToRefs(useCalendar())
function handleClosing() {
toggleCategoriesModal(false)
}
</script>
<template>
<UiAlertDialog :open="isCategoriesModalOpen">
<UiAlertDialogContent
class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl"
:disable-outside-pointer-events="true"
:trap-focus="true"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<header>
<UiAlertDialogTitle>
<span class="text-2xl">
{{ $t('entity.category.manageDialog.title') }}
</span>
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.category.manageDialog.subtitle') }}
</UiAlertDialogDescription>
</header>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClosing">
<PhX size="20" />
</UiButton>
<CalendarCategoryTable :categories />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -17,12 +17,13 @@ const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
setTimeout(() => calendarSkeletonName.value = "", 100)
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl gap-6">
<UiAlertDialogContent class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl gap-6" @close-auto-focus="(e) => e.preventDefault()">
<UiAlertDialogTitle>
<span class="text-2xl">
<strong class="font-bold">{{ world.name }}</strong>

View File

@@ -0,0 +1,62 @@
<script lang="ts" setup>
import type { RPGDate } from "~/models/Date";
const { eventSkeleton, operationInProgress } = storeToRefs(useCalendar())
const { resetSkeleton } = useCalendar()
const popoverOpen = ref(false)
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 (?)
}
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing() {
popoverOpen.value = false
resetSkeleton()
}
</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.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<CalendarFormCreateEvent @event-created="handleClosing" />
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
import type { Calendar } from "~/models/CalendarConfig";
const { toast } = useToast()
@@ -28,7 +29,7 @@ async function handleAction(): Promise<void> {
toast({
title: t("entity.calendar.deletedToast.title", { calendar: props.calendar.name }),
variant: "success",
duration: 3000
duration: ToastLifetime.SHORT
})
} catch (err) {
console.log(err)
@@ -65,6 +66,7 @@ function handleClosing() {
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<UiAlertDialogTitle>
{{ $t('entity.calendar.deleteDialog.title') }}

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
const { isDeleteEventModalOpen, eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const isLoading = ref<boolean>(false)
// Watch the popover state
watch(isDeleteEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = structuredClone(toRaw(lastActiveEvent.value))
}
})
/**
* 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()
}
}
</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>
<CalendarFormDeleteEvent />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { PhPlus } from "@phosphor-icons/vue";
const isDialogOpen = ref<boolean>(false);
const { resetSkeleton } = useCalendar();
// Toggles the dialog
function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value;
};
/**
* Prevents the modal from closing if's still loading
*/
function handleClosing() {
setTimeout(() => resetSkeleton(), 100)
}
</script>
<template>
<UiButton @click="toggleDialog">
<PhPlus size="18" weight="bold" />
<strong class="font-semibold">
{{ $t("entity.calendar.event.newEvent") }}
</strong>
</UiButton>
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent
class="border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
@escape-key-down.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<UiDialogTitle>
{{ $t("entity.calendar.event.addSingle") }}
</UiDialogTitle>
<CalendarFormCreateEvent @event-created="toggleDialog" />
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig";
import type { World } from "~/models/World";
const props = defineProps<{
calendar: Calendar | null,
world: World | null,
modalState?: boolean
}>()
const calendarSkeletonName = ref<string>(props.calendar?.name ?? "")
function onChangedName(newName: string) {
calendarSkeletonName.value = newName
}
const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
setTimeout(() => calendarSkeletonName.value = "", 100)
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="grid grid-rows-[auto_1fr_auto] items-start min-h-[66vh] max-w-4xl gap-6" @close-auto-focus="(e) => e.preventDefault()">
<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.updateDialog.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>
<CalendarFormUpdate v-if="calendar" :world="world" :calendar="calendar" @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
const { eventSkeleton, lastActiveEvent, isEditEventModalOpen } = storeToRefs(useCalendar())
const { resetSkeleton } = useCalendar();
// Watch the popover state
watch(isEditEventModalOpen, (hasOpened, _o) => {
if (hasOpened && lastActiveEvent.value) {
eventSkeleton.value = structuredClone(toRaw(lastActiveEvent.value))
}
})
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing() {
isEditEventModalOpen.value = false
setTimeout(() => resetSkeleton(), 100)
}
</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>
<CalendarFormUpdateEvent @event-updated="handleClosing" />
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -15,7 +15,7 @@ import {
} from "@phosphor-icons/vue"
const { defaultDate, getFormattedDateTitle, jumpToDate, getRelativeString, revealEditEventModal, revealDeleteEventModal } = useCalendar()
const { lastActiveEvent } = storeToRefs(useCalendar())
const { lastActiveEvent, isReadOnly } = storeToRefs(useCalendar())
const props = defineProps<{
event: CalendarEvent
@@ -43,18 +43,20 @@ function handleJumpToDate(date: RPGDate) {
const commandMenuOpened = ref(false)
function deployEditModal() {
lastActiveEvent.value = { ...props.event }
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealEditEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
/**
* Confirm event deletion
*/
function deployDeleteModal() {
lastActiveEvent.value = { ...props.event }
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealDeleteEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
</script>
@@ -72,6 +74,7 @@ function deployDeleteModal() {
'event-details': event.category
}
)"
@focus-outside.prevent
>
<div ref="eventDetails" class="grid gap-1">
<header class="pr-12">
@@ -148,7 +151,7 @@ function deployDeleteModal() {
</template>
</div>
<menu class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
<menu v-if="!isReadOnly" 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">

View File

@@ -23,13 +23,13 @@ const isPopoverDetailsOpen = ref<boolean>(false)
function handleDoubleClick() {
isPopoverDetailsOpen.value = false
lastActiveEvent.value = { ...props.event }
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealEditEventModal()
}
function handleDelete() {
isPopoverDetailsOpen.value = false
lastActiveEvent.value = { ...props.event }
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealDeleteEventModal()
}
@@ -55,12 +55,12 @@ onMounted(() => {
<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="event text-xs px-2 py-1 block w-full text-left rounded-sm transition-colors outline-offset-1"
:class="
cn(
event.category ? `event-${event.category.color}` : '',
{
'italic': event.hidden,
'is-hidden': event.hidden,
'rounded-r-none': isStartEvent,
'rounded-l-none': isEndEvent,
}

View File

@@ -2,7 +2,7 @@
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 defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: [], state: "draft", color: "white" }
const calendarSkeleton = ref<Calendar>({ ...defaultSkeleton })
onMounted(() => {
@@ -79,25 +79,25 @@ function handleFormCancel() {
<div class="flex items-center gap-1">
<PhWrench size="18" weight="fill" />
{{ $t('entity.calendar.createDialog.tabs.general.title') }}
{{ $t('entity.calendar.actionDialog.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') }}
{{ $t('entity.calendar.actionDialog.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') }}
{{ $t('entity.calendar.actionDialog.tabs.today.title') }}
</div>
</UiTabsTrigger>
</UiTabsList>
<UiTabsContent value="global">
<UiTabsContent value="global" class="grid gap-4">
<input
id="new-calendar-name"
v-model="calendarSkeleton.name"
@@ -105,9 +105,25 @@ function handleFormCancel() {
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"
class="w-full 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 px-1 grid gap-3">
<UiLabel for="new-calendar-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-calendar-state" v-model="calendarSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-calendar-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-calendar-color" v-model="calendarSkeleton.color" />
</div>
</UiTabsContent>
<UiTabsContent value="months">
<CalendarInputMonthList v-model:model-value="calendarSkeleton.months" />

View File

@@ -2,42 +2,26 @@
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 emit = defineEmits(["event-created"])
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
}
const { eventSkeleton } = storeToRefs(useCalendar())
const { submitSkeleton, cancelLatestRequest } = useCalendar()
resetSkeleton()
const newEventTitle = shallowRef<HTMLInputElement>()
useFocus(newEventTitle, { initialValue: true })
popoverOpen.value = true
const isLoading = ref(false)
// 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 (?)
}
}
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleSubmit() {
// Prevent form submission if already loading
@@ -48,7 +32,7 @@ async function handleSubmit() {
try {
await submitSkeleton()
popoverOpen.value = false
emit("event-created")
} catch (err) {
if (err instanceof Error) {
formErrors.message = err.message
@@ -58,17 +42,6 @@ async function handleSubmit() {
}
}
/**
* 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
*
@@ -81,27 +54,12 @@ function handleCancel() {
</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"
ref="newEventTitle"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
@@ -111,7 +69,6 @@ function handleCancel() {
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>
@@ -235,6 +192,4 @@ function handleCancel() {
</div>
</div>
</form>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
const { deleteCategoryFromSkeleton, cancelLatestRequest } = useCategoryStore()
const { isDeletingCategory, categorySkeleton } = storeToRefs(useCategoryStore())
const { toast } = useToast()
const { t } = useI18n()
const isLoading = ref<boolean>(false)
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleAction(): Promise<void> {
if (isLoading.value && !categorySkeleton.value) return
isLoading.value = true
const categoryName = categorySkeleton.value?.name
try {
await deleteCategoryFromSkeleton()
toast({
title: t("entity.category.deletedToast.title", { category: categoryName }),
variant: "success",
duration: ToastLifetime.MEDIUM
})
} catch (err) {
if (err instanceof Error) {
formErrors.message = err.message
}
} finally {
isLoading.value = false
}
}
/**
* 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>
<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="() => isDeletingCategory = 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>
</template>

View File

@@ -1,9 +1,10 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
const { resetSkeleton, deleteEventFromSkeleton, cancelLatestRequest } = useCalendar()
const { isDeleteEventModalOpen, eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const { isDeleteEventModalOpen, eventSkeleton } = storeToRefs(useCalendar())
const { toast } = useToast()
const { t } = useI18n()
@@ -14,13 +15,6 @@ 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
@@ -37,7 +31,7 @@ async function handleAction(): Promise<void> {
toast({
title: t("entity.calendar.event.deletedToast.title", { event: eventTitle }),
variant: "success",
duration: 3000
duration: ToastLifetime.MEDIUM
})
} catch (err) {
if (err instanceof Error) {
@@ -48,17 +42,6 @@ async function handleAction(): Promise<void> {
}
}
/**
* 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
*
@@ -71,22 +54,6 @@ function handleCancel(): void {
</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">
@@ -118,6 +85,4 @@ function handleCancel(): void {
</div>
</footer>
</form>
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,147 @@
<script lang="ts" setup>
import type { Calendar } from "~/models/CalendarConfig";
import { PhCircleNotch, PhWrench } from "@phosphor-icons/vue";
import type { World } from "~/models/World";
const props = defineProps<{
calendar: Calendar | null,
world: World | null,
}>()
const calendarSkeleton = ref<Calendar>({ ...props.calendar } as Calendar)
onMounted(() => {
calendarSkeleton.value = { ...props.calendar } as Calendar
})
type FormTabs = "global" | "months" | "today"
const activeTab = ref<FormTabs>("global")
/**
* === 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 isUpdatingCalendar = ref<boolean>(false)
async function handleSubmit() {
try {
isUpdatingCalendar.value = true
await $fetch(`/api/calendars/${calendarSkeleton.value.id}`, { method: "PATCH", body: { ...calendarSkeleton.value, worldId: props.world?.id } })
emit("on-close")
} catch (err) {
console.log(err)
} finally {
isUpdatingCalendar.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-1 mb-4">
<UiTabsTrigger value="global" class="font-bold">
<div class="flex items-center gap-1">
<PhWrench size="18" weight="fill" />
{{ $t('entity.calendar.actionDialog.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.actionDialog.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.actionDialog.tabs.today.title') }}
</div>
</UiTabsTrigger> -->
</UiTabsList>
<UiTabsContent value="global" class="grid gap-4">
<input
id="new-calendar-name"
v-model="calendarSkeleton.name"
type="text"
name="new-calendar-name"
required
:placeholder="$t('common.title')"
class="w-full 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 px-1 grid gap-3">
<UiLabel for="new-calendar-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-calendar-state" v-model="calendarSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-calendar-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-calendar-color" v-model="calendarSkeleton.color" />
</div>
</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 || isUpdatingCalendar">
<Transition name="fade">
<PhCircleNotch v-if="isUpdatingCalendar" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>
</template>

View File

@@ -1,10 +1,13 @@
<script lang="ts" setup>
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhPencilSimpleLine, PhTag } from "@phosphor-icons/vue"
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
import type { APIError } from "~/models/Errors";
const emit = defineEmits(["event-updated"])
const { resetSkeleton, updateEventFromSkeleton, cancelLatestRequest } = useCalendar()
const { eventSkeleton, lastActiveEvent, isEditEventModalOpen } = storeToRefs(useCalendar())
const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendar())
const { toast } = useToast()
const { t } = useI18n()
@@ -15,13 +18,6 @@ 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
@@ -29,6 +25,14 @@ async function handleAction() {
try {
await updateEventFromSkeleton()
emit("event-updated")
toast({
title: t("entity.calendar.event.updatedToast.title", { event: eventSkeleton.value.title }),
variant: "success",
duration: ToastLifetime.SHORT
})
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiError = (err as any).data as APIError
@@ -38,24 +42,12 @@ async function handleAction() {
title: t("entity.calendar.event.editErrors.toastTitle"),
variant: "destructive",
description: t(`entity.calendar.event.editErrors.${error.path[1]}_${error.code}`),
duration: 2000,
duration: ToastLifetime.MEDIUM,
})
})
} 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()
}
}
@@ -71,26 +63,6 @@ function handleCancel() {
</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">
@@ -105,8 +77,8 @@ function handleCancel() {
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:minlength="3"
:maxlength="120"
pattern="([A-Za--ÖØ-öø-ÿ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">
@@ -185,8 +157,8 @@ function handleCancel() {
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:minlength="3"
:maxlength="160"
pattern="([A-Za--ÖØ-öø-ÿ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"
>
@@ -239,6 +211,4 @@ function handleCancel() {
</UiButton>
</footer>
</form>
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -4,19 +4,20 @@ import { useCalendar } from "@/stores/CalendarStore"
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
const { revealAdvancedSearch } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
</script>
<template>
<header class="pt-4 border-slate-400 dark:border-slate-700 border-b-[1px]">
<div class="px-6 flex justify-between">
<header class="mt-2 grid gap-4 border-slate-200 contrast-more:border-slate-500 dark:border-slate-700 border-b-[1px]">
<div class="px-8 flex items-center justify-between gap-2">
<menu class="flex items-center gap-2">
<li class="flex items-center">
<CalendarMenuToday />
<li v-if="!isReadOnly">
<LazyCalendarDialogQuickCreateEvent />
</li>
<li>
<CalendarMenuNav />
<CalendarMenuToday />
</li>
<li class="ml-6">
<li class="ml-4">
<CalendarCurrentDate />
</li>
</menu>
@@ -25,18 +26,21 @@ const { revealAdvancedSearch } = useCalendar()
<li>
<UiButton search-slash @click="revealAdvancedSearch()">
<PhMagnifyingGlass size="20" weight="light" />
<span>
{{ $t('entity.advancedSearch.title') }}
</span>
</UiButton>
</li>
<li>
<ClientOnly>
<CalendarSwitch />
</ClientOnly>
<CalendarCategoriesCTA />
</li>
<li>
<CalendarOptionsCTA />
</li>
</menu>
</div>
<div class="ml-6">
<div class="ml-8">
<CalendarMenuSubnav />
</div>
</header>

View File

@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { PhCaretDoubleLeft, PhCaretDoubleRight, PhCaretLeft, PhCaretRight } from "@phosphor-icons/vue"
const { currentDate } = useCalendar()
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 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="toPastFar()"
>
<PhCaretDoubleLeft size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.pastFar }}</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="toPastNear()"
>
<PhCaretLeft size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.pastNear }}</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="toFutureNear()"
>
<PhCaretRight size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.futureNear }}</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="toFutureFar()"
>
<PhCaretDoubleRight size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ activeDirectionLabels.futureFar }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
</template>

View File

@@ -14,7 +14,7 @@ const buttonDisabledState: ComputedRef<boolean> = computed<boolean>(() => {
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton size="sm" :disabled="buttonDisabledState" @click="jumpToDefaultDate">
<UiButton size="sm" variant="secondary" :disabled="buttonDisabledState" @click="jumpToDefaultDate">
{{ $t('entity.calendar.date.today') }}
</UiButton>
</UiTooltipTrigger>

View File

@@ -26,9 +26,12 @@ import {
import SearchList from "./lists/SearchList.vue"
import type { Category } from "~/models/Category"
const { isAdvancedSearchOpen, allEvents } = storeToRefs(useCalendar())
const { isAdvancedSearchOpen, allEvents, categories } = storeToRefs(useCalendar())
const { characters } = storeToRefs(useCharacters())
const searchInput = shallowRef<HTMLInputElement>()
useFocus(searchInput, { initialValue: true })
const searchQuery = ref<string>("")
// const searchEnough = computed<boolean>(() => searchQuery.value.length >= 2)
@@ -204,8 +207,7 @@ watch([currentPage, selectedEntity], () => {
})
// 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 currentCategories = computed(() => categories.value)
const selectedCategories = ref<(Category)[]>([])
const categoryFilterOpened = ref<boolean>(false)
@@ -281,6 +283,7 @@ function handleCategoryUnselect(e: Category) {
<div class="relative w-full h-fit">
<UiInput
id="search"
ref="searchInput"
v-model:model-value="searchQuery"
type="text"
:placeholder="$t('entity.advancedSearch.ctaPlaceholder')"
@@ -321,7 +324,7 @@ function handleCategoryUnselect(e: Category) {
<ComboboxRoot
v-model="selectedCategories"
v-model:open="categoryFilterOpened"
v-model:searchTerm="searchCategory"
v-model:search-term="searchCategory"
class="grow flex items-center gap-y-1 pr-2"
>
<ComboboxAnchor as-child>

View File

@@ -21,16 +21,16 @@ const dateDuration = computed<string | null>(() => props.event.endDate ? getRela
<template>
<button
class="relative block w-full text-left py-3 px-4 rounded-sm transition-colors"
class="event group 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,
'pt-4 is-hidden': event.hidden,
})"
@click="$emit('query:date-jump', event.startDate)"
>
<div class="flex gap-2 items-center mb-1">
<h2 class="font-bold text-lg">
<h2 class="font-bold text-lg underline-offset-4 group-hover:underline">
{{ event.title }}
</h2>
<div v-if="event.wiki">

View File

@@ -60,7 +60,7 @@ const pagedResults = computed(() => sortedResults.value.slice(props.startAt, pro
</script>
<template>
<ul class="grid gap-4">
<ul class="grid gap-4 pl-8 pr-12">
<li v-for="r in pagedResults" :key="isCalendarEvent(r) ? r.title : r.name">
<EventCallout v-if="isCalendarEvent(r)" :event="r" @query:date-jump="handleJumpToDate" />

View File

@@ -5,7 +5,7 @@ import { useElementBounding } from "@vueuse/core"
import { storeToRefs } from "pinia"
import { computed, ref, type ComputedRef } from "vue"
import CalendarEventButton from "../../CalendarEvent.vue"
import CalendarEventButton from "../../event/Event.vue"
const props = defineProps<{
date: RPGDate
@@ -16,7 +16,7 @@ const calendarTile = ref()
const calendarEventsList = ref()
const { defaultDate, selectDate, areDatesIdentical } = useCalendar()
const { selectedDate, currentEvents } = storeToRefs(useCalendar())
const { selectedDate, currentEvents, isReadOnly } = storeToRefs(useCalendar())
/**
* All events with a startDate / endDate that starts or ends on the tile
@@ -73,7 +73,7 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
<template>
<div
ref="calendarTile"
class="tile relative text-xs p-2 border-slate-400 dark:border-slate-700 dark:bg-black transition-colors"
class="tile relative text-xs p-2"
:class="{
'text-slate-300 dark:text-slate-500': props.faded,
'text-slate-500 dark:text-slate-300': !props.faded
@@ -143,20 +143,17 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
</ClientOnly>
<ClientOnly>
<LazyCalendarFormCreateEvent :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
<LazyCalendarDialogCreateEvent v-if="!isReadOnly" :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
</ClientOnly>
</div>
</template>
<style lang="scss" scoped>
// .tile are the direct children of the grid
// We need to get, between each element, a 1px solid line to separate them, for columns and rows
.tile {
&:not(:nth-child(10n)) {
border-right-width: 1px;
}
&:nth-child(n + 11) {
border-top-width: 1px;
}
border-bottom-width: 1px;
}
.event-enter-active {

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import { PhPlusCircle } from "@phosphor-icons/vue";
const emit = defineEmits(["on-click"])
</script>
<template>
<UiCard
class="h-full lg:w-fit transition-all bg-transparent hover:text-slate-900 text-slate-500 dark:hover:text-slate-100 dark:text-slate-500 dark:focus-within:outline-gray-900 hover:-translate-y-0"
has-click
@on-click="emit('on-click')"
>
<UiCardHeader class="h-full justify-center items-center text-center gap-0">
<PhPlusCircle size="38" weight="fill" />
<UiCardTitle class="text-xl">
<slot />
</UiCardTitle>
</UiCardHeader>
</UiCard>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { PhCaretRight } from "@phosphor-icons/vue";
const route = useRoute()
export type BreadcrumbItem = {
label?: string;
translateKey?: string;
to?: string;
}
defineProps<{
items?: BreadcrumbItem[];
}>()
</script>
<template>
<UiBreadcrumb>
<UiBreadcrumbList>
<UiBreadcrumbItem v-if="route.path !== '/my'">
<UiBreadcrumbLink as-child>
<NuxtLink to="/my">
{{ $t("breadcrumbs.profile") }}
</NuxtLink>
</UiBreadcrumbLink>
</UiBreadcrumbItem>
<UiBreadcrumbItem v-else>
<UiBreadcrumbPage>
{{ $t("breadcrumbs.profile") }}
</UiBreadcrumbPage>
</UiBreadcrumbItem>
<template v-for="(item, index) in items" :key="index">
<UiBreadcrumbSeparator>
<PhCaretRight />
</UiBreadcrumbSeparator>
<UiBreadcrumbItem>
<UiBreadcrumbLink v-if="item.to" as-child>
<NuxtLink :to="item.to">
<template v-if="item.label">
{{ item.label }}
</template>
<template v-else>
{{ $t(`breadcrumbs.${item.translateKey}`) }}
</template>
</NuxtLink>
</UiBreadcrumbLink>
<UiBreadcrumbPage v-else>
<template v-if="item.label">
{{ item.label }}
</template>
<template v-else>
{{ $t(`breadcrumbs.${item.translateKey}`) }}
</template>
</UiBreadcrumbPage>
</UiBreadcrumbItem>
</template>
</UiBreadcrumbList>
</UiBreadcrumb>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
type HeadingLevel = "h1" | "h2" | "h3"
type HeadingLevel = "h0" | "h1" | "h2" | "h3"
interface HeadingProps {
level?: HeadingLevel
@@ -11,13 +11,16 @@ withDefaults(defineProps<HeadingProps>(), {
</script>
<template>
<h1 v-if="level === 'h1'" class="text-4xl font-bold flex">
<h1 v-if="level === 'h0'" class="text-4xl md:text-6xl font-bold flex">
<slot />
</h1>
<h2 v-else-if="level === 'h2'" class="text-2xl font-bold flex">
<h1 v-else-if="level === 'h1'" class="text-2xl md:text-4xl font-bold flex">
<slot />
</h1>
<h2 v-else-if="level === 'h2'" class="text-xl md:text-2xl font-bold flex">
<slot />
</h2>
<h3 v-if="level === 'h3'" class="text-xl font-bold flex">
<h3 v-if="level === 'h3'" class="text-lg md:text-xl font-bold flex">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
const { lineNb = 3 } = defineProps<{
lineNb?: number
}>()
</script>
<template>
<UiCard class="w-full h-full flex flex-col">
<UiCardHeader>
<UiCardTitle>
<UiSkeleton class="w-2/3 max-w-full h-8" />
</UiCardTitle>
</UiCardHeader>
<UiCardContent class="grow grid gap-2">
<UiSkeleton class="w-full max-w-full h-6" />
<template v-for="(l, i) in lineNb" :key="i">
<UiSkeleton class="max-w-full h-6" :style="`width: ${Math.floor(Math.random() * 100) + 1}%`" />
</template>
</UiCardContent>
</UiCard>
</template>

View File

@@ -1,17 +1,38 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import { type RPGColor, rpgColors } from "~/models/Color";
const model = defineModel<RPGColor>()
const { id, theme = "normal", position = "popper" } = defineProps<{
id: string
theme?: "normal" | "subtle"
position?: "item-aligned" | "popper" | undefined
}>();
const model = defineModel<RPGColor>({ default: "white" });
</script>
<template>
<UiSelect v-model="model">
<UiSelectTrigger>
<UiSelectValue :placeholder="$t('ui.colors.selectOne')" />
<UiSelectTrigger :id :class="cn({ 'h-9': theme === 'subtle' })">
<UiSelectValue
:placeholder="$t('ui.colors.selectOne')"
class="bgc"
:class="cn(`bgc-${model}`)"
/>
</UiSelectTrigger>
<UiSelectContent>
<UiSelectContent :position>
<UiSelectGroup>
<UiSelectItem v-for="color in rpgColors" :key="color" :value="color">
<UiSelectItem
v-for="color in rpgColors"
:key="color"
:value="color"
class="bgc"
:class="
cn(
`bgc-${color}`,
)
"
>
{{ $t(`ui.colors.${color}`) }}
</UiSelectItem>
</UiSelectGroup>

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { contentStates, type ContentState } from "~/models/Entity";
defineProps<{
id: string
}>();
const model = defineModel<ContentState>({ default: "draft" });
</script>
<template>
<UiSelect v-model="model">
<UiSelectTrigger :id>
<UiSelectValue
:placeholder="$t('ui.contentState.selectOne')"
/>
</UiSelectTrigger>
<UiSelectContent position="popper">
<UiSelectGroup>
<UiSelectItem
v-for="state in contentStates"
:key="state"
:value="state"
>
{{ $t(`ui.contentState.${state}`) }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
</template>

View File

@@ -1,21 +1,30 @@
<script lang="ts" setup>
import { PhGlobeHemisphereWest, PhList } from "@phosphor-icons/vue"
import type { SidebarMenuActionType } from "./SidebarProps";
import { PhCompass, PhGlobeHemisphereEast, PhHurricane, PhList } from "@phosphor-icons/vue"
import type { SidebarMenuActionType, SidebarMenuIcon } from "./SidebarProps";
const { revealAdvancedSearch } = useCalendar()
const { currentMenu } = storeToRefs(useUiStore())
const user = useSupabaseUser()
function handleMenuItemAction(actionType: SidebarMenuActionType) {
if (actionType === "event-search") {
revealAdvancedSearch()
}
}
function computeMenuItemIcon(iconString: SidebarMenuIcon) {
switch (iconString) {
case "universe":
return PhHurricane
case "world":
return PhGlobeHemisphereEast
default:
return PhCompass
}
}
</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">
<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 after:opacity-50 after:contrast-125 dark:after:opacity-75 dark:after:contrast-175 after:-hue-rotate-60">
<menu class="flex flex-col gap-4">
<li class="mb-12">
<UiButton variant="ghost" size="icon" class="rounded-full" @click="console.log">
@@ -23,36 +32,49 @@ function handleMenuItemAction(actionType: SidebarMenuActionType) {
</UiButton>
</li>
<li v-if="user">
<li>
<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 to="/explore">
<PhCompass size="24" weight="fill" />
</RouterLink>
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent :side="'right'" :side-offset="6">
<p>
{{ $t('entity.world.namePlural') }}
{{ $t('pages.explore.menuLabel') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</li>
<ClientOnly>
<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>
<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" />
<component :is="computeMenuItemIcon(item.phIcon)" size="24" :weight="item.phIconWeight || '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
v-if="item.action"
variant="ghost"
size="icon"
class="rounded-full"
@click="handleMenuItemAction(item.action!)"
>
<component :is="computeMenuItemIcon(item.phIcon)" size="24" :weight="item.phIconWeight || 'fill'" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent :side="'right'" :side-offset="6">
@@ -61,8 +83,32 @@ function handleMenuItemAction(actionType: SidebarMenuActionType) {
</UiTooltip>
</UiTooltipProvider>
</li>
</ClientOnly>
</menu>
<UserCTA />
</nav>
</template>
<style lang="scss" scoped>
nav {
position: relative;
isolation: isolate;
&::after {
display: block;
content: '';
position: absolute;
bottom: 0;
inset-inline: 0;
height: 25rem;
max-height: 100%;
background-image: url("/images/sidebar.png");
background-position: bottom;
background-size: cover;
background-repeat: no-repeat;
mask-image: linear-gradient(to top, black 25%, transparent 50%, transparent);
z-index: -1;
}
}
</style>

View File

@@ -1,9 +1,11 @@
import type { ShallowRef } from "vue"
export type SidebarMenuActionType = "event-search"
export type SidebarMenuIcon = "universe" | "world"
export interface SidebarMenuItem {
phIcon: ShallowRef // use shallowRef to build phIcon
phIcon: SidebarMenuIcon,
phIconWeight?: "regular" | "light" | "fill" | "duotone" | "bold" | "thin"
highlight?: boolean
tooltip: string
action?: SidebarMenuActionType
to?: string

View File

@@ -1,7 +1,7 @@
<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 { PhCheckCircle, PhUser, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle } from "@phosphor-icons/vue"
import { cn } from "~/lib/utils";
const router = useRouter()
@@ -60,7 +60,7 @@ function pushRoute(to: AvailableRoutes) {
<ClientOnly>
<UiDropdownMenu v-model:open="menuOpened">
<UiDropdownMenuTrigger>
<UiAvatar v-if="user" class="cursor-pointer">
<UiAvatar v-if="user" id="user-avatar" class="ring-[.2rem] ring-indigo-700 dark:ring-neutral-900 cursor-pointer">
<UiAvatarImage
:src="userMeta?.avatar_url"
:alt="userMeta?.full_name"
@@ -70,7 +70,7 @@ function pushRoute(to: AvailableRoutes) {
{{ $t('ui.sidebarMenu.avatarFallback') }}
</UiAvatarFallback>
</UiAvatar>
<UiButton v-else variant="outline" size="icon" class="rounded-full">
<UiButton v-else variant="outline" size="icon" class="rounded-full border-indigo-200 bg-indigo-700 dark:border-slate-300 dark:bg-neutral-950 dark:hover:bg-slate-50 dark:hover:text-slate-950 cursor-pointer">
<PhUserCircle size="24" />
</UiButton>
</UiDropdownMenuTrigger>
@@ -82,9 +82,9 @@ function pushRoute(to: AvailableRoutes) {
</p>
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="pushRoute('/my')">
<PhGlobeHemisphereWest size="20" weight="fill" />
<PhUser size="20" weight="fill" />
<span>
{{ $t('entity.world.namePlural') }}
{{ $t('ui.sidebarMenu.profile') }}
</span>
</UiDropdownMenuItem>
@@ -186,12 +186,12 @@ function pushRoute(to: AvailableRoutes) {
<UiDropdownMenuSeparator />
<template v-if="user">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="pushRoute('/my/settings')">
<!-- <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> -->
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="handleLogout">
<PhSignOut size="20" weight="fill" />
<span>

View File

@@ -25,13 +25,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<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"
class="fixed inset-0 z-50 bg-black/60 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',
'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 border-indigo-200 bg-background dark:bg-slate-950 dark:border-indigo-950 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,
)
"

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<nav aria-label="breadcrumb" :class="props.class">
<slot />
</nav>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { MoreHorizontal } from "lucide-vue-next"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
role="presentation"
aria-hidden="true"
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="h-4 w-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { PrimitiveProps } from "radix-vue"
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Primitive } from "radix-vue"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
as: "a",
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn('transition-colors hover:text-foreground', props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<ol
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>

View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('font-normal text-foreground', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { ChevronRight } from "lucide-vue-next"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:w-3 [&>svg]:h-3', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Breadcrumb } from "./Breadcrumb.vue"
export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue"
export { default as BreadcrumbItem } from "./BreadcrumbItem.vue"
export { default as BreadcrumbLink } from "./BreadcrumbLink.vue"
export { default as BreadcrumbList } from "./BreadcrumbList.vue"
export { default as BreadcrumbPage } from "./BreadcrumbPage.vue"
export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue"

View File

@@ -28,7 +28,7 @@ const props = withDefaults(defineProps<Props>(), {
v-if="props.searchSlash"
class="h-4 p-1 ml-1 grid place-items-center text-[0.7em] leading-none font-semibold text-slate-500 bg-slate-100 border-slate-300 border-[1px] rounded-[3px] shadow-sm group-hover:text-slate-600 group-hover:bg-slate-200 group-hover:border-slate-400 transition-colors"
>
<span class="-mt-[2px]">CTRL + :</span>
<kbd class="-mt-[2px]">CTRL + :</kbd>
</span>
</Primitive>
</template>

View File

@@ -10,7 +10,7 @@ export const buttonVariants = cva(
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",
secondary: "bg-secondary text-secondary-foreground hover:bg-primary/20",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},

View File

@@ -5,7 +5,10 @@ import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
link?: string
hasClick?: boolean
}>()
const emit = defineEmits(["on-click"])
</script>
<template>
@@ -13,7 +16,7 @@ const props = defineProps<{
:class="
cn('rounded-lg border bg-card text-card-foreground shadow-sm transition-all isolate', props.class, {
'relative outline outline-2 outline-offset-4 outline-transparent hover:-translate-y-[.2rem]':
props.link
props.link || props.hasClick
})
"
>
@@ -24,5 +27,9 @@ const props = defineProps<{
:to="props.link"
class="absolute inset-0 z-10 focus-visible:outline-none"
/>
<button
v-if="props.hasClick"
class="absolute inset-0 z-10 focus-visible:outline-none"
@click="emit('on-click')" />
</div>
</template>

View File

@@ -8,7 +8,7 @@ const props = defineProps<{
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<div :class="cn('flex items-center p-6 border-t-2 border-t-slate-900', props.class)">
<slot />
</div>
</template>

View File

@@ -27,13 +27,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<DialogOverlay
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"
class="fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-4xl -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',
'fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-4xl -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background border-indigo-200 dark:bg-slate-950 dark:border-indigo-950 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
)
"

View File

@@ -27,7 +27,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { DropdownMenuArrow, type DropdownMenuArrowProps, useForwardProps } from "radix-vue"
const props = defineProps<DropdownMenuArrowProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuArrow class="fill-border" v-bind="forwardedProps">
<slot />
</DropdownMenuArrow>
</template>

View File

@@ -5,10 +5,10 @@ import {
type DropdownMenuSubTriggerProps,
useForwardProps
} from "radix-vue"
import { ChevronRight } from "lucide-vue-next"
import { cn } from "@/lib/utils"
import { PhCaretLeft, PhCaretRight } from "@phosphor-icons/vue";
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], arrowDirection?: "left" | "right" }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
@@ -29,7 +29,17 @@ const forwardedProps = useForwardProps(delegatedProps)
)
"
>
<template v-if="props.arrowDirection === 'left'">
<PhCaretLeft class="mr-auto h-4 w-4" />
<span class="inline-block">
<slot />
<ChevronRight class="ml-auto h-4 w-4" />
</span>
</template>
<template v-else>
<slot />
<PhCaretRight class="ml-auto h-4 w-4" />
</template>
</DropdownMenuSubTrigger>
</template>

View File

@@ -1,6 +1,7 @@
export { DropdownMenuPortal } from "radix-vue"
export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuArrow } from "./DropdownMenuArrow.vue"
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"

View File

@@ -17,7 +17,7 @@ const delegatedProps = computed(() => {
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer',
props.class,
)
"

View File

@@ -36,9 +36,10 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
style="max-height: var(--radix-select-content-available-height)"
:class="
cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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',
'select-content relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class

View File

@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background dark:bg-slate-950 contrast-more:dark:bg-slate-900 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption :class="cn('mt-4 text-sm text-muted-foreground', props.class)">
<slot />
</caption>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
:class="
cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
props.class,
)
"
>
<slot />
</td>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { computed } from "vue"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot :class="cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', props.class)">
<slot />
</tfoot>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
<slot />
</tr>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'

View File

@@ -5,6 +5,12 @@ import type { ToastProps } from "."
const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 1000000
export enum ToastLifetime {
SHORT = 2000,
MEDIUM = 3500,
LONG = 6000,
}
export type StringOrVNode =
| string
| VNode

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import { PhArchive, PhFile, PhFileDashed, PhFilePlus, PhPencil, PhPencilSimpleLine, PhTrash } from "@phosphor-icons/vue";
import type { World } from "~/models/World";
import { DateTime } from "luxon";
const props = defineProps<{
world: World
}>();
const emit = defineEmits(["on-edit", "on-delete"]);
const { locale } = useI18n();
const createdAt = DateTime.fromISO(props.world.createdAt!).toLocaleString(DateTime.DATETIME_MED, { locale: locale.value });
const updatedAt = computed<string>(() => props.world.updatedAt ? DateTime.fromISO(props.world.updatedAt).toLocaleString(DateTime.DATETIME_MED, { locale: locale.value }) : "");
</script>
<template>
<UiCard
class="w-full h-full flex flex-col transition-all"
:link="`/my/worlds/${world.id}`"
:class="{
'hover:bg-slate-50 dark:hover:bg-sky-950 dark:focus-within:outline-sky-900': !world.color,
'bg-red-100 dark:bg-red-950 border-red-200 hover:bg-red-50 dark:hover:bg-red-900 dark:border-red-900 dark:focus-within:outline-red-900': world.color === 'red',
'bg-orange-100 dark:bg-orange-950 border-orange-200 hover:bg-orange-50 dark:hover:bg-orange-900 dark:border-orange-900 dark:focus-within:outline-orange-900': world.color === 'orange',
'bg-amber-100 dark:bg-amber-950 border-amber-200 hover:bg-amber-50 dark:hover:bg-amber-900 dark:border-amber-900 dark:focus-within:outline-amber-900': world.color === 'amber',
'bg-yellow-100 dark:bg-yellow-950 border-yellow-200 hover:bg-yellow-50 dark:hover:bg-yellow-900 dark:border-yellow-900 dark:focus-within:outline-yellow-900': world.color === 'yellow',
'bg-lime-100 dark:bg-lime-950 border-lime-200 hover:bg-lime-50 dark:hover:bg-lime-900 dark:border-lime-900 dark:focus-within:outline-lime-900': world.color === 'lime',
' bg-green-100 dark:bg-green-950 border-green-200 hover:bg-green-50 dark:hover:bg-green-900 dark:border-green-900 dark:focus-within:outline-green-900': world.color === 'green',
'bg-emerald-100 dark:bg-emerald-950 border-emerald-200 hover:bg-emerald-50 dark:hover:bg-emerald-900 dark:border-emerald-900 dark:focus-within:outline-emerald-900': world.color === 'emerald',
'bg-teal-100 dark:bg-teal-950 border-teal-200 hover:bg-teal-50 dark:hover:bg-teal-900 dark:border-teal-900 dark:focus-within:outline-teal-900': world.color === 'teal',
'bg-cyan-100 dark:bg-cyan-950 border-cyan-200 hover:bg-cyan-50 dark:hover:bg-cyan-900 dark:border-cyan-900 dark:focus-within:outline-cyan-900': world.color === 'cyan',
'bg-sky-100 dark:bg-sky-950 border-sky-200 hover:bg-sky-50 dark:hover:bg-sky-900 dark:border-sky-900 dark:focus-within:outline-sky-900': world.color === 'sky',
'bg-blue-100 dark:bg-blue-950 border-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900 dark:border-blue-900 dark:focus-within:outline-blue-900': world.color === 'blue',
'bg-indigo-100 dark:bg-indigo-950 border-indigo-200 hover:bg-indigo-50 dark:hover:bg-indigo-900 dark:border-indigo-900 dark:focus-within:outline-indigo-900': world.color === 'indigo',
'bg-violet-100 dark:bg-violet-950 border-violet-200 hover:bg-violet-50 dark:hover:bg-violet-900 dark:border-violet-900 dark:focus-within:outline-violet-900': world.color === 'violet',
'bg-purple-100 dark:bg-purple-950 border-purple-200 hover:bg-purple-50 dark:hover:bg-purple-900 dark:border-purple-900 dark:focus-within:outline-purple-900': world.color === 'purple',
'bg-fuchsia-100 dark:bg-fuchsia-950 border-fuchsia-200 hover:bg-fuchsia-50 dark:hover:bg-fuchsia-900 dark:border-fuchsia-900 dark:focus-within:outline-fuchsia-900': world.color === 'fuchsia',
'bg-pink-100 dark:bg-pink-950 border-pink-200 hover:bg-pink-50 dark:hover:bg-pink-900 dark:border-pink-900 dark:focus-within:outline-pink-900': world.color === 'pink',
'bg-pink-100 dark:bg-rose-950 border-rose-200 hover:bg-rose-50 dark:hover:bg-rose-900 dark:border-rose-900 dark:focus-within:outline-rose-900': world.color === 'rose',
'text-slate-100 bg-slate-900 border-slate-700 hover:bg-slate-700 dark:hover:bg-slate-800 dark:border-slate-900 dark:focus-within:outline-slate-900': world.color === 'black',
' hover:bg-slate-50 dark:hover:text-slate-900 dark:hover:bg-slate-200 dark:border-slate-700 dark:focus-within:outline-slate-100': world.color === 'white',
}"
>
<UiCardHeader class="gap-4">
<UiCardTitle>
{{ world.name }}
</UiCardTitle>
<div v-if="world.state === 'published'" class="flex items-center gap-1 text-sm">
<PhFile size="20" weight="fill" />
<span>{{ $t('ui.contentState.published') }}</span>
</div>
<div v-if="world.state === 'draft'" class="flex items-center gap-1 text-sm">
<PhFileDashed size="20" weight="fill" />
<span>{{ $t('ui.contentState.draft') }}</span>
</div>
<div v-if="world.state === 'archived'" class="flex items-center gap-1 text-sm">
<PhArchive size="20" weight="fill" />
<span>{{ $t('ui.contentState.archived') }}</span>
</div>
</UiCardHeader>
<UiCardContent class="grow">
<p class="italic">{{ world.description }}</p>
<div class="flex gap-1 absolute top-4 right-4 z-20">
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-indigo-400 dark:hover:bg-indigo-700" @click="emit('on-edit')">
<PhPencil size="16" />
</UiButton>
<UiButton size="icon" variant="ghost" class=" hover:text-white hover:bg-rose-400 dark:hover:bg-rose-700" @click="emit('on-delete')">
<PhTrash size="16" />
</UiButton>
</div>
</UiCardContent>
<UiCardFooter>
<ul class="grid gap-1 text-sm">
<li class="flex gap-1 items-center">
<PhFilePlus size="18" />
<span>{{ $t('common.createdAt', { createdAt }) }}</span>
</li>
<li v-if="updatedAt" class="flex gap-1 items-center">
<PhPencilSimpleLine size="18" />
<span>{{ $t('common.updatedAt', { updatedAt }) }}</span>
</li>
</ul>
</UiCardFooter>
</UiCard>
</template>

View File

@@ -15,12 +15,13 @@ const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
worldSkeletonName.value = ""
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="gap-4">
<UiAlertDialogContent class="gap-4" @close-auto-focus="(e) => e.preventDefault()">
<header>
<UiAlertDialogTitle>
<span class="text-2xl">

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
import type { World } from "~/models/World";
const { toast } = useToast()
@@ -28,7 +29,7 @@ async function handleAction(): Promise<void> {
toast({
title: t("entity.world.deletedToast.title", { world: props.world.name }),
variant: "success",
duration: 3000
duration: ToastLifetime.SHORT
})
} catch (err) {
console.log(err)
@@ -65,6 +66,7 @@ function handleClosing() {
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<UiAlertDialogTitle>
{{ $t('entity.world.deleteDialog.title') }}

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
import type { World } from "~/models/World";
const props = defineProps<{
modalState?: boolean
world: World | null
}>()
const worldSkeletonName = ref<string>(props.world?.name ?? "")
watch(() => props.world, (newWorld) => {
worldSkeletonName.value = newWorld?.name ?? ""
})
function onChangedName(newName: string) {
worldSkeletonName.value = newName
}
const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
worldSkeletonName.value = props.world?.name ?? ""
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent class="gap-4" @close-auto-focus="(e) => e.preventDefault()">
<header>
<UiAlertDialogTitle>
<span class="text-2xl">
<span v-if="worldSkeletonName">
{{ $t('entity.world.editDialog.title') }} — {{ worldSkeletonName }}
</span>
<span v-else>
{{ $t('entity.world.editDialog.title') }}
</span>
</span>
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.world.editDialog.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>
<WorldFormUpdate :world @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -4,7 +4,7 @@ import type { World } from "~/models/World";
const user = useSupabaseUser()
const defaultWorld: World = { name: "", description: "", gmId: user.value?.id }
const defaultWorld: World = { name: "", description: "", gmId: user.value?.id, color: "white", state: "draft" }
const worldSkeleton = ref<World>({ ...defaultWorld })
onMounted(() => {
@@ -57,7 +57,7 @@ function handleFormCancel() {
<template>
<template v-if="worldSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<div>
<div class="grid gap-4">
<input
id="new-world-name"
v-model="worldSkeleton.name"
@@ -69,10 +69,6 @@ function handleFormCancel() {
@input="handleNameChange"
>
<div class="-mx-1 mb-4">
<InputColor v-model="worldSkeleton.color" />
</div>
<textarea
id="new-world-description"
v-model="worldSkeleton.description"
@@ -80,6 +76,22 @@ function handleFormCancel() {
: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 class="-mx-1 grid gap-3">
<UiLabel for="new-world-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-world-state" v-model="worldSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-world-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-world-color" v-model="worldSkeleton.color" />
</div>
</div>
<footer class="flex justify-end gap-2 mt-6">

View File

@@ -0,0 +1,126 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { useToast } from "~/components/ui/toast";
import { ToastLifetime } from "~/components/ui/toast/use-toast";
import type { World } from "~/models/World";
const { toast } = useToast()
const { t } = useI18n()
const user = useSupabaseUser()
const props = defineProps<{
world: World | null
}>()
const worldSkeleton = ref<World>({ ...props.world } as World)
onMounted(() => {
worldSkeleton.value = { ...props.world } as World
})
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 || !worldSkeleton.value) return
try {
isLoading.value = true
await $fetch(`/api/worlds/${worldSkeleton.value.id}`, { method: "PATCH", body: { ...worldSkeleton.value } })
toast({
title: t("entity.world.updatedToast.title", { world: worldSkeleton.value.name }),
variant: "success",
duration: ToastLifetime.SHORT
})
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 class="grid gap-4">
<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 py-2 -mx-1 px-1 text-xl border-b-[1px] bg-transparent focus-visible:outline-none focus-visible:border-blue-600"
@input="handleNameChange"
>
<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 class="-mx-1 grid gap-3">
<UiLabel for="new-world-state">
{{ $t('ui.contentState.label') }}
</UiLabel>
<InputContentState id="new-world-state" v-model="worldSkeleton.state" />
</div>
<div class="-mx-1 grid gap-3">
<UiLabel for="new-world-color">
{{ $t('ui.colors.label') }}
</UiLabel>
<InputColor id="new-world-color" v-model="worldSkeleton.color" />
</div>
</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>

104
error.vue Normal file
View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
import { PhArrowBendDoubleUpLeft, PhBugBeetle, PhImageBroken, PhLinkBreak } from "@phosphor-icons/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 props = defineProps({
// eslint-disable-next-line vue/require-default-prop
error: Object as () => NuxtError
})
const { statusCode } = props.error!
let titleKey: string
let descriptionKey: string
let subDescriptionKey: string
switch (statusCode) {
case 404:
titleKey = "error.notFound.title"
descriptionKey = "error.notFound.descriptionMain"
subDescriptionKey = "error.notFound.descriptionSub"
break;
case 500:
titleKey = "error.unknownServer.title"
descriptionKey = "error.unknownServer.descriptionMain"
subDescriptionKey = "error.unknownServer.descriptionSub"
break;
default:
titleKey = "error.default.title"
descriptionKey = "error.default.descriptionMain"
subDescriptionKey = "error.default.descriptionSub"
break;
}
</script>
<template>
<div class="h-screen">
<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">
<div class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t(titleKey) }}</Title>
</Head>
<div class="grid text-center justify-items-center opacity-80">
<PhImageBroken v-if="statusCode === 404" size="100" class="opacity-60" />
<PhLinkBreak v-else-if="statusCode === 500" size="100" class="opacity-60" />
<PhBugBeetle v-else size="100" class="opacity-60" />
<Heading level="h0">
{{ $t(titleKey) }}
</Heading>
<div class="mt-6 md:text-lg">
<p>{{ $t(descriptionKey) }}</p>
<p>{{ $t(subDescriptionKey) }}</p>
</div>
<UiButton variant="default" class="mt-6 gap-2" as-child>
<RouterLink to="/">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('ui.backToHome') }}
</RouterLink>
</UiButton>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrapper > * {
height: 100%;
}
</style>

View File

@@ -8,6 +8,23 @@ export default defineI18nConfig(() => ({
title: "Fantasy calendars for TTRPGs",
description: "Tools for players and game masters to help them visualize fantasy worlds better.",
},
error: {
default: {
title: "An error occured",
description: "An error occured while loading the page.",
descriptionSub: "Please try again later or contact the administrator !"
},
notFound: {
title: "Page not found",
description: "The page you're looking for doesn't exist.",
descriptionSub: "Maybe it has been deleted or moved ?"
},
unknownServer: {
title: "Internal server error",
description: "An error occured while loading the page.",
descriptionSub: "Please try again later or contact the administrator !"
},
},
ui: {
action: {
back: "Back",
@@ -17,6 +34,7 @@ export default defineI18nConfig(() => ({
edit: "Edit",
},
colors: {
label: "Color",
selectOne: "Select a color",
red: "Red",
orange: "Orange",
@@ -38,9 +56,19 @@ export default defineI18nConfig(() => ({
black: "Black",
white: "White",
},
contentState: {
label: "Status",
selectOne: "Select a publication state",
published: "Published",
draft: "Draft",
archived: "Archived",
},
greeting: "Connected as {user}",
anonymousGreeting: "Preferences",
backToProfile: "Back to profile",
backToHome: "Back to home",
sidebarMenu: {
profile: "Profile",
appearance: "Appearance",
language: "Language",
account: "Account",
@@ -51,10 +79,12 @@ export default defineI18nConfig(() => ({
dark: "Dark",
light: "Light",
system: "System",
displayMode: "Display mode"
displayMode: "Display"
},
common: {
title: "Title"
title: "Title",
createdAt: "Created {createdAt}",
updatedAt: "Last updated {updatedAt}",
},
entity: {
category: {
@@ -63,13 +93,32 @@ export default defineI18nConfig(() => ({
search: "Search categories",
notFoundAny: "No categories found.",
addPrimary: "Add a primary category",
addSecondaries: "Add secondary categories"
addSecondaries: "Add secondary categories",
manageDialog: {
title: "Manage categories",
subtitle: "Add and change the categories of your calendar",
},
deleteDialog: {
title: "Delete this category ?",
subtitle: "The events attached to this category won't be deleted, but you'll lose the category for this calendar.",
},
addedToast: {
title: "The category \"{category}\" has been added to the calendar.",
titleError: "An error has occured and the category \"{category}\" wasn't added to the calendar.",
},
updatedToast: {
title: "The category \"{category}\" has been successfuly updated.",
titleError: "An error has occured and the category \"{category}\" wasn't updated.",
},
deletedToast: {
title: "The category \"{category}\" has been successfuly deleted.",
},
},
isLoading: "Loading in progress…",
addDescription: "Add a description",
deleteOne: "Delete \"{entity}\"",
advancedSearch: {
title: "Advanced search",
title: "Search",
subtitle: "Search through calendar and world data",
older: "Older",
newer: "Newer",
@@ -78,15 +127,29 @@ export default defineI18nConfig(() => ({
world: {
nameSingular: "World",
namePlural: "Worlds",
backToMy: "Back to my universe",
backToSingle: "Back to {world}",
addSingle: "Add a world",
addSingleFirst: "Add your first world !",
editSingle: "Edit world",
notFound: "World not found",
notFoundDescription: "The link is not valid or the world has been deleted / archived.",
backToList: "Go back to worlds",
createDialog: {
title: "Create a world",
subtitle: "Worlds are the building blocks which hold all your characters, your calendars…"
},
editDialog: {
title: "Edit world",
subtitle: "Update world data",
},
deleteDialog: {
title: "Delete this world ?",
subtitle: "This world will be deleted permanently, and all of its associated data will be lost !",
},
updatedToast: {
title: "The world \"{world}\" has been successfuly updated.",
},
deletedToast: {
title: "The world \"{world}\" has been successfuly deleted.",
},
@@ -94,9 +157,19 @@ export default defineI18nConfig(() => ({
calendar: {
nameSingular: "Calendar",
namePlural: "Calendars",
namePublicSingular: "Public Calendar",
namePublicPlural: "Public Calendars",
addSingle: "Add a calendar",
addSingleFirst: "Add your first calendar !",
notFound: "Calendar not found",
notFoundDescription: "The link is not valid or the calendar has been deleted / archived.",
notFoundForWorld: "No calendar for this world… yet !",
backToList: "Go back to calendars",
isLoading: "Calendar is loading…",
hasXEvent: "{count} available event",
hasXEvents: "{count} available events",
seeCategories: "Modify categories",
seeOptions: "Calendar options",
date: {
start: "Start date",
end: "End date",
@@ -127,24 +200,31 @@ export default defineI18nConfig(() => ({
event: {
nameSingular: "Event",
namePlural: "Events",
addSingle: "Add an event",
newEvent: "New event",
title: "Event title",
isStart: "Start",
isEnd: "End",
isHidden: "Hidden event",
isPublic: "Public event",
isHidden: "Hidden",
isPublic: "Public",
hiddenTooltip: "This event is visible only to game masters.",
addLocation: "Add a place",
prevPage: "Previous page with events",
nextPage: "Next page with events",
outOfBoundsTitle: "No next or previous events were found",
outOfBoundsMessage: "Maybe the event is the first or last one ?",
patterns: {
title: "Between 3 and 120 caracters, without special symbols, except & - ~ ()",
title: "Between 3 and 120 caracters.",
description: "1200 caracters max.",
location: "Between 3 and 160 caracters, without special symbols, except & - ~ ()",
location: "Between 3 and 160 caracters.",
},
editDialog: {
title: "Edit event",
subtitle: "Update event data",
},
updatedToast: {
title: "Event \"{event}\" has been successfuly updated.",
},
editErrors: {
toastTitle: "Event wasn't updated.",
title_too_big: "Title should be less than 120 characters long.",
@@ -159,8 +239,7 @@ export default defineI18nConfig(() => ({
title: "The event \"{event}\" has been successfuly deleted.",
},
},
createDialog: {
title: "Create a calendar",
actionDialog: {
tabs: {
general: {
title: "General",
@@ -173,6 +252,9 @@ export default defineI18nConfig(() => ({
},
}
},
createDialog: {
title: "Create a calendar",
},
deleteDialog: {
title: "Are you sure you want to delete this calendar ?",
subtitle: "Its events won't be accessible anymore and you won't be able to retrieve the deleted data !",
@@ -182,26 +264,31 @@ export default defineI18nConfig(() => ({
},
millennia: {
nameSingular: "Millennia",
displayMode: "Millennial",
nextSingular: "Next millennia",
prevSingular: "Last millennia",
},
centuries: {
nameSingular: "Century",
displayMode: "Centuries",
nextSingular: "Next century",
prevSingular: "Last century",
},
decades: {
nameSingular: "Decade",
displayMode: "Decadal",
nextSingular: "Next decade",
prevSingular: "Last decade",
},
years: {
nameSingular: "Year",
displayMode: "Yearly",
nextSingular: "Next year",
prevSingular: "Last year",
},
months: {
nameSingular: "Month",
displayMode: "Monthly",
nextSingular: "Next month",
prevSingular: "Last month",
inputName: "Month's name",
@@ -220,6 +307,23 @@ export default defineI18nConfig(() => ({
nameSingular: "Character",
namePlural: "Characters",
}
},
pages: {
explore: {
menuLabel: "Explore",
title: "Explore worlds",
},
profile: {
title: "{user} — My universe",
metaTitle: "My universe",
}
},
breadcrumbs: {
home: "Home",
explore: "Explore",
profile: "Universe",
world: "World",
calendar: "Calendar"
}
},
fr: {
@@ -227,6 +331,23 @@ export default defineI18nConfig(() => ({
title: "Calendriers fantasies pour JDR",
description: "Outils destinés aux joueurs et maîtres de jeux pour visualiser plus facilement leurs univers.",
},
error: {
default: {
title: "Une erreur est survenue",
descriptionMain: "Une erreur est survenue lors du chargement de la page.",
descriptionSub: "Merci de rééssayer plus tard ou de contacter l'administrateur !"
},
notFound: {
title: "Page introuvable",
descriptionMain: "La page que vous cherchez n'existe pas.",
descriptionSub: "Peut-être a t-elle été supprimée ou déplacée ?"
},
unknownServer: {
title: "Une erreur du serveur est survenue",
descriptionMain: "Une erreur est survenue lors du chargement de la page.",
descriptionSub: "Merci de rééssayer plus tard ou de contacter l'administrateur !"
},
},
ui: {
action: {
back: "Retour",
@@ -236,6 +357,7 @@ export default defineI18nConfig(() => ({
edit: "Modifier",
},
colors: {
label: "Couleur",
selectOne: "Sélectionner une couleur",
red: "Rouge",
orange: "Orange",
@@ -257,9 +379,19 @@ export default defineI18nConfig(() => ({
black: "Noir",
white: "Blanc",
},
contentState: {
label: "État de publication",
selectOne: "Modifier l'état de publication",
published: "Publié",
draft: "Brouillon",
archived: "Archivé",
},
greeting: "Connecté en tant que {user}",
anonymousGreeting: "Préférences",
backToProfile: "Retour au profil",
backToHome: "Retourner à l'accueil",
sidebarMenu: {
profile: "Profil",
appearance: "Apparence",
language: "Langue",
account: "Compte",
@@ -270,10 +402,12 @@ export default defineI18nConfig(() => ({
dark: "Sombre",
light: "Clair",
system: "Système",
displayMode: "Mode d'affichage",
displayMode: "Affichage",
},
common: {
title: "Titre"
title: "Titre",
createdAt: "Créé le {createdAt}",
updatedAt: "Dernière modification le {updatedAt}",
},
entity: {
category: {
@@ -282,13 +416,32 @@ export default defineI18nConfig(() => ({
search: "Rechercher les catégories",
notFoundAny: "Aucune catégorie trouvée.",
addPrimary: "Ajouter une catégorie principale",
addSecondaries: "Ajouter des catégories secondaires"
addSecondaries: "Ajouter des catégories secondaires",
manageDialog: {
title: "Gestion des catégories",
subtitle: "Créer et modifier les catégories de votre calendrier",
},
deleteDialog: {
title: "Supprimer cette catégorie ?",
subtitle: "Les évènements l'utilisant ne seront pas supprimés.",
},
addedToast: {
title: "La catégorie \"{category}\" a été ajoutée au calendrier.",
titleError: "Une erreur s'est produite et la catégorie \"{category}\" n'a pas pu être ajoutée.",
},
updatedToast: {
title: "La catégorie \"{category}\" a été modifiée avec succès.",
titleError: "Une erreur s'est produite et la catégorie \"{category}\" n'a pas pu être modifiée.",
},
deletedToast: {
title: "La catégorie \"{category}\" a été supprimée avec succès.",
},
},
isLoading: "Chargement en cours…",
addDescription: "Ajouter une description",
deleteOne: "Supprimer \"{entity}\"",
advancedSearch: {
title: "Recherche avancée",
title: "Rechercher",
subtitle: "Rechercher les données disponibles sur le calendrier",
older: "Plus ancien",
newer: "Plus récent",
@@ -297,25 +450,49 @@ export default defineI18nConfig(() => ({
world: {
nameSingular: "Monde",
namePlural: "Mondes",
backToMy: "Retourner à mon univers",
backToSingle: "Retourner sur {world}",
addSingle: "Ajouter un monde",
addSingleFirst: "Ajouter votre premier monde !",
editSingle: "Modifier le monde",
notFound: "Aucun monde trouvé",
notFoundDescription: "Le lien n'est pas valide ou le monde a été supprimé / archivé.",
backToList: "Retourner aux mondes",
createDialog: {
title: "Créer un monde",
subtitle: "Un monde est la brique de base qui contient vos personnages, vos calendriers…"
},
editDialog: {
title: "Modifier le monde",
subtitle: "Mettre à jour les données du monde",
},
deleteDialog: {
title: "Supprimer ce monde ?",
subtitle: "Le monde sera supprimé définitivement, vous perdrez toutes les données associées !",
},
updatedToast: {
title: "Le monde \"{world}\" a été modifié avec succès.",
},
deletedToast: {
title: "Le monde \"{world}\" a été supprimé avec succès.",
},
},
calendar: {
nameSingular: "Calendriers",
namePlural: "Calendrier",
nameSingular: "Calendrier",
namePlural: "Calendriers",
namePublicSingular: "Calendrier Public",
namePublicPlural: "Calendriers Publics",
addSingle: "Ajouter un calendrier",
addSingleFirst: "Ajouter votre premier calendrier !",
notFound: "Aucun calendrier trouvé",
notFoundDescription: "Le lien n'est pas valide ou le calendrier a été supprimé / archivé.",
notFoundForWorld: "Aucun calendrier pour ce monde… pour l'instant !",
backToList: "Retourner aux calendriers",
isLoading: "Chargement du calendrier…",
hasXEvent: "{count} évènement disponible",
hasXEvents: "{count} évènements disponibles",
seeCategories: "Gestion des catégories",
seeOptions: "Options du calendrier",
date: {
start: "Date de début",
end: "Date de fin",
@@ -346,24 +523,31 @@ export default defineI18nConfig(() => ({
event: {
nameSingular: "Évènement",
namePlural: "Évènements",
addSingle: "Ajouter un évènement",
newEvent: "Nouvel évènement",
title: "Titre de l'évènement",
isStart: "Début",
isEnd: "Fin",
isHidden: "Évènement privé",
isPublic: "Évènement public",
isHidden: "Privé",
isPublic: "Public",
hiddenTooltip: "Cet évènement est uniquement visible pour les maîtres du jeu.",
addLocation: "Ajouter un endroit",
prevPage: "Précédente page à évènements",
nextPage: "Prochaine page à évènements",
outOfBoundsTitle: "Aucun évènement suivant ou précédent trouvé",
outOfBoundsMessage: "Peut-être l'évènement se situe au début ou à la fin du calendrier ?",
patterns: {
title: "Entre 3 et 120 charactères, sans symboles spéciaux, sauf & - ~ ()",
title: "Entre 3 et 120 charactères.",
description: "Maximum 1200 charactères.",
location: "Entre 3 et 160 charactères, sans symboles spéciaux, sauf & - ~ ()",
location: "Entre 3 et 160 charactères.",
},
editDialog: {
title: "Modifier l'évènement",
subtitle: "Mettre à jour les données de l'évènement",
},
updatedToast: {
title: "L'évènement \"{event}\" a été modifié avec succès.",
},
editErrors: {
toastTitle: "L'évènement n'a pas été modifié",
title_too_big: "Le titre doit être inférieur à 120 caractères.",
@@ -378,8 +562,7 @@ export default defineI18nConfig(() => ({
title: "L'évènement \"{event}\" a été supprimé avec succès.",
},
},
createDialog: {
title: "Créer un calendrier",
actionDialog: {
tabs: {
general: {
title: "Général",
@@ -392,6 +575,12 @@ export default defineI18nConfig(() => ({
},
}
},
createDialog: {
title: "Créer un calendrier",
},
updateDialog: {
title: "Modifier le calendrier",
},
deleteDialog: {
title: "Êtes-vous sûr de supprimer ce calendrier ?",
subtitle: "Les évènements ne seront plus accessibles et vous ne pourrez plus récupérer les données !",
@@ -401,26 +590,31 @@ export default defineI18nConfig(() => ({
},
millennia: {
nameSingular: "Millénaire",
displayMode: "Par millénaire",
nextSingular: "Millénaire suivant",
prevSingular: "Millénaire précédent",
},
centuries: {
nameSingular: "Siècle",
displayMode: "Par siècle",
nextSingular: "Siècle suivant",
prevSingular: "Siècle précédent",
},
decades: {
nameSingular: "Décennie",
displayMode: "Par décennie",
nextSingular: "Décennie suivante",
prevSingular: "Décennie précédente",
},
years: {
nameSingular: "Année",
displayMode: "Annuel",
nextSingular: "Année suivante",
prevSingular: "Année précédente",
},
months: {
nameSingular: "Mois",
displayMode: "Mensuel",
nextSingular: "Mois suivant",
prevSingular: "Mois précédent",
inputName: "Nom du mois",
@@ -440,6 +634,23 @@ export default defineI18nConfig(() => ({
nameSingular: "Personnage",
namePlural: "Personnages",
}
},
pages: {
explore: {
menuLabel: "Explorer",
title: "Explorer les mondes",
},
profile: {
title: "{user} — Mon univers",
metaTitle: "Mon univers",
}
},
breadcrumbs: {
home: "Accueil",
explore: "Explorer",
profile: "Univers",
world: "Monde",
calendar: "Calendrier"
}
}
}

4
middleware/resetMenu.ts Normal file
View File

@@ -0,0 +1,4 @@
export default defineNuxtRouteMiddleware(() => {
const { resetMenu } = useUiStore()
resetMenu()
})

View File

@@ -2,6 +2,10 @@ import { z } from "zod"
import type { CalendarEvent } from "./CalendarEvent"
import { calendarMonthSchema, type CalendarMonth } from "./CalendarMonth"
import { dateSchema, type RPGDate } from "./Date"
import type { World } from "./World"
import type { ContentState } from "./Entity"
import type { RPGColor } from "./Color"
import type { Category } from "./Category"
export interface CalendarConfig {
months: CalendarMonth[]
@@ -10,15 +14,26 @@ export interface CalendarConfig {
export interface Calendar extends CalendarConfig {
id?: number
shortId?: string
name: string
events: CalendarEvent[]
color?: string
events: CalendarEvent[],
categories: Category[]
eventNb?: Array<{ count: number }>
state: ContentState
color?: RPGColor
world?: World
createdAt?: string
updatedAt?: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CalendarChannelPayload = Calendar & Record<string, any>
export const postCalendarSchema = z.object({
name: z.string(),
today: dateSchema.optional().nullable(),
color: z.string().optional().nullable(),
months: z.array(calendarMonthSchema).min(1),
worldId: z.number().int(),
state: z.string().optional().nullable().default("draft"),
})

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { categorySchema, type Category } from "./Category"
import type { Category } from "./Category"
import { dateSchema, type RPGDate } from "./Date"
export interface CalendarEvent {
@@ -26,7 +26,11 @@ export const postEventBodySchema = z.object({
startDate: dateSchema.required(),
endDate: dateSchema.optional().nullable(),
hidden: z.boolean().optional().nullable(),
category: categorySchema.optional().nullable(),
category: z.object({
id: z.number().int(),
name: z.string(),
color: z.string().default("black")
}).optional().nullable(),
}),
calendarId: z.number({ coerce: true }).int().positive()
})

View File

@@ -2,13 +2,15 @@ import { z } from "zod"
import type { RPGColor } from "./Color"
export interface Category {
id: number
id?: number
name: string
color?: RPGColor
}
export const categorySchema = z.object({
id: z.number().int(),
category: z.object({
name: z.string(),
color: z.string().default("black")
color: z.string().default("black"),
}),
calendarId: z.number({ coerce: true }).int().positive()
})

2
models/Entity.ts Normal file
View File

@@ -0,0 +1,2 @@
export const contentStates = ["published", "draft", "archived"] as const
export type ContentState = typeof contentStates[number]

View File

@@ -1,19 +1,27 @@
import { z } from "zod"
import type { Calendar } from "./CalendarConfig"
import { rpgColorSchema, type RPGColor } from "./Color"
import type { ContentState } from "./Entity"
export interface World {
id?: number
name: string
description?: string
color?: RPGColor,
calendars?: Calendar[],
color?: RPGColor
calendars?: Calendar[]
gmId?: string
state?: ContentState
createdAt?: string
updatedAt?: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WorldChannelPayload = World & Record<string, any>
export const postWorldSchema = z.object({
name: z.string(),
description: z.string().optional().nullable(),
color: rpgColorSchema,
color: rpgColorSchema.default("white"),
gmId: z.string().optional().nullable(),
state: z.string().optional().nullable().default("draft"),
})

View File

@@ -1,6 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: false },
devtools: { enabled: true },
modules: [
"@nuxtjs/supabase",
@@ -13,6 +13,7 @@ export default defineNuxtConfig({
"@nuxtjs/i18n",
"@nuxthub/core"
],
css: ["~/assets/main.css"],
i18n: {
@@ -37,7 +38,11 @@ export default defineNuxtConfig({
supabase: {
redirectOptions: {
login: "/",
callback: "/my/"
callback: "/my/",
exclude: [
"/calendars(/*)?",
"/explore(/*)?"
]
}
},
@@ -47,14 +52,18 @@ export default defineNuxtConfig({
autoprefixer: {},
},
},
shadcn: {
prefix: "Ui",
componentDir: "./components/ui"
},
colorMode: {
classSuffix: "",
preference: "dark",
fallback: "dark",
},
eslint: {}
eslint: {},
compatibilityDate: "2025-02-27"
})

View File

@@ -13,44 +13,46 @@
"lint:prettier": "prettier . --check"
},
"dependencies": {
"@nuxt/eslint": "^0.3.12",
"@nuxthub/core": "^0.8.7",
"@nuxtjs/i18n": "^8.5.1",
"@nuxt/eslint": "^1.1.0",
"@nuxthub/core": "^0.8.17",
"@nuxtjs/i18n": "^9.2.1",
"@phosphor-icons/vue": "^2.2.1",
"@pinia/nuxt": "^0.5.1",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.11.0",
"@vueuse/nuxt": "^10.9.0",
"class-variance-authority": "^0.7.0",
"@pinia/nuxt": "^0.10.1",
"@vueuse/core": "^12.7.0",
"@vueuse/integrations": "^12.7.0",
"@vueuse/nuxt": "^12.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.364.0",
"nuxt": "^3.7.0",
"pinia": "^2.1.7",
"radix-vue": "^1.7.4",
"shadcn-nuxt": "^0.10.4",
"sortablejs": "^1",
"tailwind-merge": "^2.3.0",
"lucide-vue-next": "^0.475.0",
"luxon": "^3.5.0",
"nuxt": "^3.15.4",
"pinia": "^3.0.1",
"radix-vue": "^1.9.16",
"shadcn-nuxt": "^0.11.4",
"sortablejs": "^1.15.6",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.4.27",
"vue-router": "^4.3.2",
"zod": "^3.23.8"
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.4.1",
"@nuxtjs/supabase": "^1.2.2",
"@nuxtjs/tailwindcss": "^6.12.0",
"@stylistic/eslint-plugin-js": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.26.0",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"sass": "^1.72.0",
"tailwindcss": "^3.4.3",
"typescript": "~5.4.0",
"wrangler": "^3.90.0"
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/supabase": "^1.4.6",
"@nuxtjs/tailwindcss": "^6.13.1",
"@stylistic/eslint-plugin-js": "^3.1.0",
"@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.0.2",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-vue": "^9.32.0",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
"sass": "^1.85.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"wrangler": "^3.111.0"
}
}

84
pages/calendars/[id].vue Normal file
View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig";
const route = useRoute()
const shortId = route.params.id
const user = useSupabaseUser()
const {
data: calendar,
status: calendarStatus,
refresh: calRefresh
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{
key: "active-calendar",
query: {
shortId,
full: true
}
}
)
const isLoading = computed(() => calendarStatus.value === "pending")
watch(user, () => calRefresh())
const { setActiveCalendar } = useCalendar()
watch(isLoading, (n) => {
if (!n && calendar.value?.data) {
setActiveCalendar(calendar.value?.data)
}
}, { immediate: true }
)
</script>
<template>
<div v-if="isLoading" class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.calendar.nameSingular") }}</Title>
</Head>
<div class="grid gap-2 justify-items-center opacity-50">
<p>
{{ $t('entity.calendar.isLoading') }}
</p>
<PhCircleNotch size="50" class="animate-spin"/>
</div>
</div>
<div v-else-if="calendar?.data" class="h-full w-full pt-8">
<Head>
<Title>{{ calendar.data.name }}</Title>
</Head>
<Calendar />
</div>
<div v-else class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.calendar.notFound") }}</Title>
</Head>
<div class="grid justify-items-center opacity-80">
<PhCalendarX size="75" class="opacity-60" />
<LazyHeading level="h1">
{{ $t("entity.calendar.notFound") }}
</LazyHeading>
<p>
{{ $t('entity.calendar.notFoundDescription') }}
</p>
<LazyUiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/explore">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.calendar.backToList') }}
</RouterLink>
</LazyUiButton>
</div>
</div>
</template>

40
pages/explore.vue Normal file
View File

@@ -0,0 +1,40 @@
<script lang="ts" setup>
import type { Calendar } from "~/models/CalendarConfig";
definePageMeta({
middleware: ["reset-menu"]
})
const { data: availableCalendars, status: calendarStatus } = await useLazyFetch<{ data: Calendar[] }>("/api/calendars/query", { key: "explore-calendars", query: { full: true } })
const isLoading = computed(() => calendarStatus.value === "pending")
</script>
<template>
<main class="p-8">
<Head>
<Title>{{ $t("pages.explore.title") }}</Title>
</Head>
<Spacing size="lg">
<Heading level="h1">
{{ $t("pages.explore.title") }}
</Heading>
<Spacing size="lg">
<Heading level="h2">
{{ $t("entity.calendar.namePublicPlural") }}
</Heading>
<div v-if="isLoading" class="grid md:grid-cols-3 gap-2">
<LoadingCard />
</div>
<ul v-else-if="availableCalendars?.data" class="grid md:grid-cols-3 gap-2">
<li v-for="calendar in availableCalendars.data" :key="calendar.shortId">
<CalendarPreviewCard :calendar="calendar" :gm-id="calendar.world?.gmId" />
</li>
</ul>
</Spacing>
</Spacing>
</main>
</template>

View File

@@ -2,6 +2,10 @@
useHead({
titleTemplate: null
})
definePageMeta({
middleware: ["reset-menu"]
})
</script>
<template>

View File

@@ -1,13 +1,13 @@
<script lang="ts" setup>
import { PhCircleNotch } from "@phosphor-icons/vue";
import { PhArrowBendDoubleUpLeft, PhCalendarX, PhCircleNotch } from "@phosphor-icons/vue";
import type { Calendar } from "~/models/CalendarConfig";
import type { Category } from "~/models/Category";
definePageMeta({
middleware: ["auth-guard"]
})
const user = useSupabaseUser()
// Redirect user back home when they log out on the page
watch(user, (n) => {
if (!n) {
@@ -18,15 +18,31 @@ watch(user, (n) => {
const route = useRoute()
const id = route.params.id
const { data: calendarData, pending: calPending } = await useLazyFetch("/api/calendars/query", { key: `calendar-${id}`, query: { id, full: true } })
const { data: catData, pending: catPending } = await useLazyFetch("/api/calendars/categories/query", { key: `categories-${id}` })
const {
data: calendar,
status: calendarStatus
} = await useLazyFetch<{ data: Calendar }>("/api/calendars/query",
{
key: "active-calendar",
query: {
id,
full: true
}
}
)
const cal = computed<Calendar>(() => calendarData?.value?.data as Calendar)
const categories = computed<Category[]>(() => catData?.value?.data as Category[])
const isLoading = computed(() => calendarStatus.value === "pending")
const { setActiveCalendar } = useCalendar()
watch([calendar], () => {
if (calendar.value?.data) {
setActiveCalendar(calendar.value?.data)
}
}, { immediate: true })
</script>
<template>
<div v-if="calPending || catPending" class="h-full w-full grid place-items-center">
<div v-if="isLoading" class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.calendar.nameSingular") }}</Title>
</Head>
@@ -39,11 +55,48 @@ const categories = computed<Category[]>(() => catData?.value?.data as Category[]
</div>
</div>
<div v-else-if="cal && categories" class="h-full w-full">
<div v-else-if="calendar?.data" class="h-full w-full">
<Head>
<Title>{{ cal.name }}</Title>
<Title>{{ calendar.data.name }}</Title>
</Head>
<Calendar :calendar-data="cal" :categories />
<div class="h-full grid grid-rows-[auto_1fr] pt-8 gap-y-2">
<div class="px-8">
<Breadcrumb
v-if="calendar.data.world"
:items="[
{ label: calendar.data.world.name, to: `/my/worlds/${calendar.data.world.id}` },
{ label: calendar.data.name },
]"
/>
</div>
</template>
<Calendar />
</div>
</div>
<div v-else class="h-full w-full grid place-items-center">
<Head>
<Title>{{ $t("entity.calendar.notFound") }}</Title>
</Head>
<div class="grid justify-items-center opacity-80">
<PhCalendarX size="75" class="opacity-60" />
<Heading level="h1">
{{ $t("entity.calendar.notFound") }}
</Heading>
<p>
{{ $t('entity.calendar.notFoundDescription') }}
</p>
<UiButton variant="default" class="mt-4 gap-2" as-child>
<RouterLink to="/my">
<PhArrowBendDoubleUpLeft size="24" />
{{ $t('entity.calendar.backToList') }}
</RouterLink>
</UiButton>
</div>
</div></template>

Some files were not shown because too many files have changed in this diff Show More