424 Commits

Author SHA1 Message Date
Alexis
674a38c30d Fixed search shortcut 2025-08-24 11:38:10 +02:00
Alexis
e7cef314a1 Fixed untranslated string 2025-08-24 11:26:11 +02:00
Alexis
806d3a7908 Updated package 2025-08-23 22:45:35 +02:00
Alexis
6251499940 Added sheet for sidebar mobile 2025-08-15 15:37:35 +02:00
Alexis
f3d5db76ac Removed sidebar toggle on public pages 2025-08-12 11:18:47 +02:00
Alexis
08620b5044 Made content body smaller 2025-08-12 11:16:52 +02:00
Alexis
f89cc22e87 Fixed quick create dialog not resetting skeleton 2025-08-12 11:09:28 +02:00
Alexis
51c678d27e Fixed dashboard link hitbox 2025-08-12 10:44:56 +02:00
Alexis
b9b891c61f Fixed nav colors in dark mode 2025-08-12 10:43:49 +02:00
Alexis
df06228e68 Fixed transition render issue 2025-08-12 10:37:37 +02:00
Alexis
44193aa018 Added about link to CTA 2025-08-12 10:34:23 +02:00
Alexis
73eceda96c Changed secondary button color 2025-08-12 10:18:07 +02:00
Alexis
e305b21f80 Changed projects page handle 2025-08-12 10:11:28 +02:00
Alexis
d2b66a42dd Fixed dark mode select hover 2025-08-12 10:07:12 +02:00
Alexis
be32a018c6 Added input focus on entity forms 2025-08-11 20:23:25 +02:00
Alexis
a5a232bac7 Added navbar on public pages 2025-08-11 20:00:04 +02:00
Alexis
20045b125e Changed explore page h2 2025-08-09 21:06:42 +02:00
Alexis
f1a3854d1d Merge branch 'feat/drag-events' into dev 2025-08-09 18:56:57 +02:00
Alexis
cebcb50903 Added tooltip on rich text 2025-08-09 18:55:55 +02:00
Alexis
396215bb91 Fixed some UI issues with drag state 2025-08-09 16:40:50 +02:00
Alexis
7468374bb5 Added update logic for single day events 2025-08-09 16:24:59 +02:00
Alexis
5a421cf07f Merge branch 'feat/editor' into dev 2025-08-08 10:49:14 +02:00
Alexis
25a9099a18 Added rich text to event description 2025-08-08 10:48:29 +02:00
Alexis
c208393f9f Bigger richtext window 2025-08-07 23:01:34 +02:00
Alexis
d75f3b5423 Added control options 2025-08-07 22:55:53 +02:00
Alexis
97ce5b3553 Added simple tiptap component 2025-08-07 22:37:49 +02:00
Alexis
0a35f441e0 Merge branch 'code/cleanup' into dev 2025-08-04 22:43:49 +02:00
Alexis
c8a345406c Updated packages and some other colours 2025-08-04 22:43:38 +02:00
Alexis
119968e7fc Fixed a bunch of color transition issues on theme switch 2025-08-04 21:48:57 +02:00
Alexis
9b8f48ad8e Fixed tooltip position on client side component 2025-08-04 16:45:46 +02:00
Alexis
2199359b96 Fixed badge color 2025-08-04 13:41:43 +02:00
Alexis
422673cc4c Updated color scheme 2025-08-04 12:31:28 +02:00
Alexis
6aad01c96f Updated color scheme and identity 2025-08-03 19:42:43 +02:00
Alexis
0c96466263 Updated data loading strategy on world page 2025-08-02 22:42:23 +02:00
Alexis
7697e818bf Fixed supabase channel conflicts 2025-08-01 17:18:06 +02:00
Alexis
fd766b9f42 Updated packages 2025-07-29 22:45:22 +02:00
Alexis
0e7b60235a Fixed log in animation 2025-07-29 22:30:14 +02:00
Alexis
300507bbed Made sidebar image smaller 2025-07-29 21:59:36 +02:00
Alexis
693f506610 Reworked data fetching for explore page 2025-07-27 18:57:31 +02:00
Alexis
f2076e413c Made about page more reactive 2025-07-27 18:17:13 +02:00
Alexis
6b02629b01 Fixed nuxt database warning 2025-07-27 17:31:21 +02:00
Alexis
1d8b56c58e Merge branch 'upgrade/nuxt-4' into dev 2025-07-27 15:14:33 +02:00
Alexis
8290d02900 Updated nvm version 2025-07-27 15:14:28 +02:00
Alexis
b67f663f05 Updated legal text as well 2025-07-27 15:12:15 +02:00
Alexis
456135d679 Fixed better sqlite 3 not being installed 2025-07-27 15:04:26 +02:00
Alexis
7fdab8601f Migration to nuxt 4
Used codemods CLI and reworked most alias'd path that stopped working
2025-07-27 14:30:30 +02:00
Alexis
68b9a38f83 Updated other major packages 2025-07-26 23:36:10 +02:00
Alexis
77752e8164 Updated nuxt i18n to v10 2025-07-26 23:32:32 +02:00
Alexis
1d74d6f64b Updated zod to v4 2025-07-26 23:18:09 +02:00
Alexis
b0975ee293 Updated median and minor packages 2025-07-26 23:01:08 +02:00
Alexis
33f4f78ea7 Merge branch 'code/cleanup' into dev 2025-07-26 18:47:27 +02:00
Alexis
4a04728183 Fixed about page being auth guarded 2025-07-26 18:47:16 +02:00
Alexis
8cd20aa386 Removed vector from supabase
This resolves all the issues with unhealthy containers, idk why it still doesn't work in 2025
2025-07-17 22:02:37 +02:00
Alexis
8e325196e6 Updated packages 2025-05-13 11:22:27 +02:00
Alexis
47883d1577 More legal stuff changes 2025-05-13 11:05:33 +02:00
Alexis
4433f5f1b6 Fixed some content rules 2025-05-07 14:54:13 +02:00
Alexis
f132e6d1e0 Added container to about page 2025-05-07 14:34:46 +02:00
Alexis
245fb421b3 Fixed redundant part of digit regex 2025-05-07 14:31:35 +02:00
Alexis
46d7aa74f9 Removed useless assignement 2025-05-07 14:26:19 +02:00
Alexis
9e40ab0982 Changed constructor to cast 2025-05-07 14:16:14 +02:00
Alexis
63d5ba2224 Changed sort to toSorted 2025-05-07 14:12:21 +02:00
Alexis
114e235a93 Removed useless type inference 2025-05-07 14:10:51 +02:00
Alexis
f03d952183 Fixed aria combobox attributes 2025-05-07 14:03:49 +02:00
Alexis
45eefbe35d Removed a leftover comment 2025-05-07 13:57:32 +02:00
Alexis
18f332be54 Removed unused imports 2025-05-07 13:45:20 +02:00
Alexis
67b79000f6 Removed some commented code 2025-05-07 13:40:50 +02:00
Alexis
896bac5f83 Changed defineEmits to be more legible 2025-05-07 13:37:23 +02:00
Alexis
09cd25ce33 Removed duplicate character in regex 2025-05-07 13:18:01 +02:00
Alexis
1cf8510a7b First draft of legal page 2025-05-07 13:15:01 +02:00
Alexis
a83096a17b Added scannerwork to gitignore 2025-05-07 13:14:41 +02:00
Alexis
216617a8e6 Updated deps 2025-05-05 16:42:03 +02:00
Alexis
a668fa8d81 Hotfix for previous ui feature 2025-04-30 17:00:28 +02:00
AlexisNP
0fdb1508a4 Merge pull request #80 from AlexisNP/ui/event-visibility-checkbox
UI/event visibility checkbox
2025-04-30 14:05:10 +02:00
Alexis
d7447eb700 Changed toggle to checkbox 2025-04-30 14:02:02 +02:00
AlexisNP
c2712f7b86 Merge pull request #79 from AlexisNP/features/mobile-calendars
Features/mobile calendars
2025-04-29 16:19:14 +02:00
Alexis
6b01ce181d Added back breadcrumb 2025-04-29 16:16:45 +02:00
Alexis
acb5c0d92f Added middleware for closing sidebar on route change 2025-04-29 09:34:58 +02:00
Alexis
387597755d Added sidebar toggle 2025-04-29 09:28:49 +02:00
Alexis
a5f7e1ded9 Added first sidebar draft 2025-04-28 17:18:06 +02:00
Alexis
93a03ca4e2 Removed breadcrumbs on mobile 2025-04-28 12:27:10 +02:00
Alexis
a6fe4c7ea3 Categories appear on more options 2025-04-28 11:29:33 +02:00
Alexis
805972bf3c Fixed css color issue 2025-04-26 21:16:35 +02:00
Alexis
c071e9c33b Added some transitions 2025-04-26 21:01:52 +02:00
Alexis
b430ab2660 Changed head description 2025-04-26 20:32:31 +02:00
Alexis
a6d388f5da Added missing description to event creation dialog 2025-04-26 20:19:11 +02:00
Alexis
a03b7890e6 Made dialog go full screen on mobile 2025-04-25 21:30:40 +02:00
Alexis
b71e715cf5 Fixed call stack issue 2025-04-25 21:06:19 +02:00
Alexis
9ba09c2d2b Fixed black button for day tile 2025-04-25 17:56:56 +02:00
Alexis
b1ff2ec232 Changed today CTA on mobile 2025-04-25 17:53:40 +02:00
Alexis
093b4e7493 Fixed overflow issues on search 2025-04-25 17:36:54 +02:00
Alexis
7b0bb1e939 Made event creation dialog behaviour the same 2025-04-25 17:33:45 +02:00
Alexis
60bbb6f48f Fixed no contrast event buttons 2025-04-25 17:30:50 +02:00
Alexis
f502f80341 Updated list style 2025-04-25 17:25:01 +02:00
Alexis
e634cc8127 Fixed some inconsistencies 2025-04-25 16:56:09 +02:00
Alexis
ee29169f4f Added transitions for client only buttons 2025-04-25 15:38:37 +02:00
Alexis
2d2069cae6 Fixed hydration mismatches 2025-04-25 15:23:03 +02:00
Alexis
f7f12feaec Changed popover to modal on small screens 2025-04-25 14:58:47 +02:00
Alexis
5b8fedba7c Added space-x for alert dialog 2025-04-25 14:17:48 +02:00
Alexis
fa872e6914 Fixed collision padding for calendar events 2025-04-25 14:15:20 +02:00
Alexis
43dd12782b Reduced spacing on calendar menu 2025-04-23 21:59:45 +02:00
Alexis
f1f140f2bc Reduced some spacing on mobile 2025-04-23 21:41:32 +02:00
Alexis
707d4374ef Changed calendar layout for buttons 2025-04-23 16:21:51 +02:00
AlexisNP
048413286f Merge pull request #77 from AlexisNP/upgrade/nuxt
Upgrade/nuxt
2025-04-23 14:11:29 +02:00
Alexis
9d06040c5d Changed muted colour 2025-04-22 22:30:14 +02:00
Alexis
15bfc66087 Removed dark classes for calendar preview 2025-04-22 22:15:49 +02:00
Alexis
e6b2670438 Removed character string 2025-04-22 17:17:13 +02:00
Alexis
e2d2810db6 Event creation form closes if the date changes 2025-04-22 17:07:29 +02:00
Alexis
1827b9118f Changed chip colour for tags input 2025-04-22 15:36:58 +02:00
Alexis
086b997845 Fixed default breadcrumb look 2025-04-22 15:31:41 +02:00
Alexis
73f5e01244 Removed default lucide icons and updated some packages 2025-04-22 14:09:25 +02:00
Alexis
d02bfacd57 Made search bigger 2025-04-18 18:07:13 +02:00
Alexis
5370a9df06 Added color square to event creation forms 2025-04-18 18:03:55 +02:00
Alexis
5bd76495f4 Added color square to category filter 2025-04-18 17:59:49 +02:00
Alexis
7480447420 Standardized tilde imports for non modules 2025-04-18 17:44:21 +02:00
Alexis
034713961b Changed card color for calendars and worlds 2025-04-18 17:35:02 +02:00
Alexis
00224417be Cleaned up most vanilla try catches 2025-04-18 17:11:45 +02:00
Alexis
9b4556245e Fixed hardcoded world id for calendar creation 2025-04-17 22:34:52 +02:00
Alexis
ad8120bdd0 Removed useless lazy directives 2025-04-17 22:14:10 +02:00
Alexis
73bd79c3c5 Fixed hydration error 2025-04-17 21:47:25 +02:00
Alexis
8215791830 Changed h-w to size 2025-04-17 21:41:48 +02:00
AlexisNP
36456ce377 Merge pull request #76 from AlexisNP/upgrade/tailwind-4
Upgrade/tailwind 4
2025-04-17 19:58:02 +02:00
Alexis
015d0c4db4 Moved bgc colors to relevant file 2025-04-17 17:22:01 +02:00
Alexis
392d8cd8ec Fixed dialog animation issues 2025-04-17 17:02:42 +02:00
Alexis
473c69c9a1 Updated packages 2025-04-17 16:55:51 +02:00
Alexis
790deeec5d Updated border and event colors 2025-04-17 16:42:48 +02:00
Alexis
687b96cf12 Moved event and colored element logic to css variables 2025-04-16 19:52:19 +02:00
Alexis
3e973dd0e9 "Working" config with tailwind 4 and shadcn nuxt 2025-04-16 12:14:27 +02:00
Alexis
cc07f898ea Ran upgrade cli 2025-04-16 12:00:38 +02:00
AlexisNP
e5bc5c8a41 Merge pull request #74 from AlexisNP/features/category-management
Features/category management
2025-04-16 10:59:11 +02:00
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
Alexis
9fa6af2fa3 Merge branch 'dev' 2024-11-26 13:42:43 +01:00
Alexis
1f15a66505 Added nuxthub 2024-11-26 13:42:22 +01:00
AlexisNP
8562ea6362 Merge pull request #45 from AlexisNP/features/add-limit-to-event-text-fields
Add limit to event text fields
2024-11-25 18:17:04 +01:00
Alexis
378f07a862 User locale now saves ! 2024-11-25 18:09:18 +01:00
Alexis
d774fde1dd Added client side validation for fields 2024-11-25 17:41:40 +01:00
Alexis
863937bb16 Changed limit and added scrollbars on details 2024-11-25 16:33:02 +01:00
Alexis
372c7a49e1 Added validation for location as well 2024-11-24 20:08:30 +01:00
Alexis
c7069164b0 Added check on client side (api) 2024-11-24 19:56:00 +01:00
Alexis
5258ed3f2a Added server limit to event title and description 2024-11-24 15:30:50 +01:00
AlexisNP
66e6f84f8b Merge pull request #44 from AlexisNP/bugfix/empty-search-categories
Fixed empty categories
2024-11-22 17:54:37 +01:00
Alexis
190a94f96e Fixed category filter functions 2024-11-22 17:47:25 +01:00
Alexis
bbf1cbf84e Added API data for categories 2024-11-22 16:41:01 +01:00
AlexisNP
c58176a9e2 Merge pull request #42 from AlexisNP/bugfix/today-button-issues
Today menu is disabled only if current screen is not the default one
2024-11-19 17:26:58 +01:00
Alexis
e5fb40c24c Today menu is disabled only if current screen is not the default one 2024-11-19 17:25:45 +01:00
AlexisNP
bf36be3aa1 Merge pull request #41 from AlexisNP/features/world-managment-functions 2024-09-09 23:48:09 +02:00
Alexis
636623ee96 Added world deletion actions 2024-09-09 23:21:27 +02:00
Alexis
c19262e3c2 Added world creation dialog 2024-09-09 22:09:59 +02:00
AlexisNP
f6898da2f2 Merge pull request #37 from AlexisNP/features/toast-service 2024-09-08 19:40:56 +02:00
Alexis
0f5bc219f2 Changed slightly user menu 2024-09-08 19:37:50 +02:00
Alexis
2f79de8483 Added toast on deleted entities 2024-09-08 19:26:38 +02:00
Alexis
a69553e089 Added toast from shadcn 2024-09-08 18:10:54 +02:00
AlexisNP
680e5fe35c Merge pull request #36 from AlexisNP/features/i18n 2024-09-08 18:00:23 +02:00
Alexis
36bffbd0ab Fixed nuxt i18n cookie config 2024-09-08 17:47:58 +02:00
Alexis
63c1cef77e Added last translations for event forms / data 2024-09-08 15:30:10 +02:00
Alexis
11d900e7f7 Added compact translations for date duration 2024-09-05 21:16:24 +02:00
Alexis
f42a68dfc9 Added date computing translations 2024-09-05 21:01:26 +02:00
Alexis
74427a5056 Added relative days translations 2024-09-05 17:59:44 +02:00
Alexis
872e1be949 Updated translations for calendar time nav 2024-09-05 17:54:46 +02:00
Alexis
49cb74df7d Added extra translations to display mode 2024-09-05 17:46:32 +02:00
Alexis
6cfcde3b58 Added a bunch of translations on calendar components 2024-09-03 22:19:07 +02:00
Alexis
6fee4c7158 Added translations for more calendar forms 2024-09-03 18:23:28 +02:00
Alexis
85c661eda5 Added more translations to meta and delete modals 2024-09-02 20:07:01 +02:00
Alexis
6d66b599cf Added sidebar translations 2024-09-02 18:34:24 +02:00
Alexis
edb939bb39 Added base i18n support 2024-09-02 18:10:34 +02:00
AlexisNP
fc91e4825a Merge pull request #33 from AlexisNP/features/light-theme
Add the light theme features
2024-09-01 21:21:20 +02:00
Alexis
7f2b5e2811 Fixed light theme for yearly layout 2024-09-01 21:19:54 +02:00
Alexis
9863ac9778 Fixed badge and callout colours 2024-09-01 13:58:00 +02:00
Alexis
c1b60942d2 Fixed month list overflow when adding a calendar 2024-09-01 12:26:17 +02:00
Alexis
44b0339874 Added light friendly event details 2024-09-01 12:16:36 +02:00
Alexis
6782b9bd41 Added light friendly calendar 2024-09-01 11:23:06 +02:00
Alexis
e71a810acd Changed accent colors 2024-08-29 18:26:40 +02:00
Alexis
67aefb8bd9 Revamped main layout 2024-08-29 17:41:58 +02:00
AlexisNP
386b714f3c Merge pull request #30 from AlexisNP/bugfix/27-advanced-search-filter 2024-08-26 23:06:42 +02:00
Alexis
cbc5bbeee0 Fixed search and pagination bug 2024-08-26 22:58:53 +02:00
Alexis
168a0b61d5 Hotfix : Removed log from template 2024-08-26 22:38:27 +02:00
AlexisNP
4bc1e77e76 Merge pull request #28 from AlexisNP/features/custom-calendar-creation
LGTM
2024-08-26 22:23:16 +02:00
Alexis
ebc715a3a6 Updated title functions on pages 2024-08-26 22:07:05 +02:00
Alexis
3f201b90e3 Refactored user menu and titles 2024-08-25 18:24:59 +02:00
Alexis
8b1eb9c258 Changed user menu / sidebar 2024-08-25 16:01:23 +02:00
Alexis
23a9f68b7b Changed profile from /i to /me 2024-08-25 15:24:36 +02:00
Alexis
05ae4bfb1f Redirect to profile after signin 2024-08-25 14:37:32 +02:00
Alexis
6c73578b7f Added subscriptions to world list 2024-08-25 14:18:11 +02:00
Alexis
5a47cb3563 Changed quote styles 2024-08-25 14:14:40 +02:00
Alexis
07e97873ef First draft of calendar deletion 2024-08-25 00:35:24 +02:00
Alexis
feb04912ca Merge branch 'features/custom-calendar-creation-refactor' into features/custom-calendar-creation 2024-08-24 22:35:03 +02:00
Alexis
1985ffdbe9 Added realtime subcription to calendar page 2024-08-24 22:34:51 +02:00
Alexis
245b46661a Added loading state to create calendar form 2024-08-24 21:38:15 +02:00
Alexis
99e478951d Added cancel action for dialog create 2024-08-24 21:24:08 +02:00
Alexis
3c8a018066 Added skeleton name display on dialog 2024-08-24 19:06:21 +02:00
Alexis
69b294dcf6 Fixed supabase redirect 2024-08-24 18:37:00 +02:00
Alexis
e3a6ff8790 Added basic caching for calendar page 2024-08-24 18:33:54 +02:00
Alexis
19a71d356e Fixed horrible loading pattern for calendar 2024-08-24 14:01:16 +02:00
Alexis
23e996ee2e Reverted upgrade changes 2024-08-24 10:50:30 +02:00
Alexis
7c81d633e5 Updated nuxt and dependencies 2024-08-23 20:05:36 +02:00
Alexis
a7cf648f2f Added correct RLS and month api routes 2024-08-23 16:34:47 +02:00
Alexis
db96e66b7a Changed loading and added spinner 2024-08-21 22:52:36 +02:00
Alexis
73ff951a10 Changed layout for hidden events somewhat 2024-08-21 18:45:24 +02:00
Alexis
9f4f30cebe Fixed issue with calendar today loading 2024-08-21 13:50:37 +02:00
Alexis
1137b172e4 Removed console log 2024-08-20 19:22:27 +02:00
Alexis
9062c4f946 Fixed events not appearing correctly 2024-08-20 18:34:28 +02:00
Alexis
d54e7eb9e9 Added categories back to calendar init 2024-08-20 17:57:55 +02:00
Alexis
fe98b770e1 Formatted some function names 2024-08-20 17:34:34 +02:00
Alexis
c84099fa54 Removed default date abstraction
This would cause problems after initial store load
2024-08-20 17:29:35 +02:00
Alexis
065b83debd Fixed calendar grid not loading during refresh 2024-08-20 17:18:17 +02:00
Alexis
06dd1d976d Fixed default date loading 2024-08-20 17:17:29 +02:00
Alexis
e978acfc71 Refactored the store system 2024-08-20 09:15:05 +02:00
Alexis
7244120219 Added day selection and revamped modal structure 2024-08-17 11:33:42 +02:00
Alexis
0d57c70719 Moved month list to sub component
This is better in the long run, but it also solves the problem with useSortable ; the composable required a rerender while it's in a tabs layer
2024-06-24 18:33:38 +02:00
Alexis
12bf5139da Added tabs to form 2024-06-24 18:01:39 +02:00
Alexis
008ec4fcef Changed views data 2024-06-14 21:48:39 +02:00
Alexis
328fc555ea Fixed loading of calendar 2024-06-13 20:55:50 +02:00
Alexis
bc29129546 Changed ways to access calendars 2024-06-13 19:10:03 +02:00
AlexisNP
a7cb032b12 Merge pull request #24 from AlexisNP/features/add-categories-management-to-event-cr
Add categories to event
2024-06-11 22:36:13 +02:00
Alexis
fe8ab76aaf Separated multi and single category component
I also disabled secondary categories because they shouldn't be used right now, it adds too much complexity
2024-06-11 21:38:47 +02:00
Alexis
23365d6324 Added filtering of categories 2024-06-11 17:51:50 +02:00
Alexis
72fa9a982b Changed spacing 2024-06-11 17:16:04 +02:00
Alexis
9d44b900b0 Reverted commit 15527fd983 2024-06-11 17:08:51 +02:00
Alexis
8c6a60aa40 Lowercase category tags 2024-06-10 22:50:21 +02:00
Alexis
90be6baf42 Fixed reactivity issue with bordered event CTA 2024-06-10 22:48:28 +02:00
Alexis
87a73e7f43 Fixed event details colors 2024-06-10 22:46:59 +02:00
Alexis
9127346131 Added standardized colors 2024-06-10 22:25:34 +02:00
Alexis
69f18e9338 Added category schema validation 2024-06-10 20:43:05 +02:00
Alexis
a1aa512018 Added collision detection for category list 2024-06-10 20:26:21 +02:00
Alexis
17b9937c6a Added category input component 2024-06-10 18:58:08 +02:00
AlexisNP
e9ec2cb3ae Merge pull request #23 from AlexisNP/features/add-hidden-event-functionnality
Add hidden events UI
2024-06-09 17:27:09 +02:00
Alexis
15527fd983 Changed create event trigger to be double click
This might get reverted, I'm testing to see how it feels first
2024-06-09 17:22:56 +02:00
Alexis
92850861cd Added private event visual feedback 2024-06-09 17:20:21 +02:00
Alexis
737ee6739b Added switch component to toggle visibility 2024-06-09 14:27:53 +02:00
Alexis
43768cb49d Added shadcn switch 2024-06-09 14:03:12 +02:00
AlexisNP
2330db31dc Merge pull request #22 from AlexisNP/features/add-quick-actions-for-events
Added quick actions for events
2024-06-09 12:43:30 +02:00
Alexis
f7c135f498 Added quick delete if the popover for one event is deployed 2024-06-09 12:37:01 +02:00
Alexis
f4ad06d744 Added back button for delete modal 2024-06-09 11:37:30 +02:00
Alexis
8d19f0aca2 Made delete button destructive 2024-06-09 10:54:07 +02:00
Alexis
017b54dec7 Added pencil icon to edit modal 2024-06-09 10:49:48 +02:00
Alexis
78c1387602 Added delete for quick removal 2024-06-09 10:44:45 +02:00
Alexis
8913a7d1b3 Added double click for quick edit 2024-06-09 10:35:53 +02:00
AlexisNP
746897cb6e Merge pull request #19 from AlexisNP/bugfix/event-operations-should-have-a-loading
Bugfix/event operations should have a loading
2024-06-08 22:31:46 +02:00
Alexis
d7ecb379fd Changed alert dialog for regular dialog in update event form 2024-06-08 22:15:56 +02:00
Alexis
d88ede5e21 Removed extraneous props 2024-06-08 22:13:21 +02:00
Alexis
6bb874f799 Fixed ref being used for component and not shallowRef 2024-06-08 22:06:23 +02:00
Alexis
6738ac76c4 Added loading and cancelling to deleting popover 2024-06-08 22:00:07 +02:00
Alexis
721e26d8e4 Added loading and cancelling to updating popover 2024-06-08 21:53:16 +02:00
Alexis
a07afc8351 Prevented other popovers from opening when an operation is underway 2024-06-08 18:08:08 +02:00
Alexis
a58c1eeab9 Added request cancel on create
This seems to not be always effective on really low connections, as the cancel request doesn't seem to be instantanneous.
2024-06-08 17:48:32 +02:00
Alexis
0c48350271 Disabled close interactions on create event popover 2024-06-08 16:45:36 +02:00
Alexis
c65bcc9ae9 Added loading indicator on single create event 2024-06-08 16:22:07 +02:00
AlexisNP
7959ad2345 Merge pull request #18 from AlexisNP/bugfix/make-the-sidebar-a-global-component
Sidebar is now a global component
2024-06-08 15:02:22 +02:00
Alexis
4c0a4031a1 Reinstated isHome check on sidebar 2024-06-08 15:01:15 +02:00
Alexis
9aa2d26135 Added break-words and hypens to prevent event cta from overflowing 2024-06-08 14:49:52 +02:00
Alexis
9314a8c3c2 Removed unfinished view types 2024-06-08 14:33:34 +02:00
Alexis
3002ae593c Fixed crash with function stringify 2024-06-08 14:18:19 +02:00
Alexis
f963116018 Closed user menu on select profile 2024-06-07 19:40:55 +02:00
Alexis
4a4433dcdd Moved Sidebar to top of app
This prevents flicker and stops useless rerender of the component
2024-06-07 19:35:49 +02:00
AlexisNP
ad79ca4186 Merge pull request #17 from AlexisNP/features/add-a-location-option-for-events
Location option for events
2024-06-07 16:50:03 +02:00
Alexis
6137ba3902 Removed required field for location 2024-06-07 16:48:52 +02:00
Alexis
e63422f091 Added location to database, model, and event templates 2024-06-06 22:43:31 +02:00
AlexisNP
c6009a4bca Merge pull request #14 from AlexisNP/features/increase-event-label-to-2-lines
Added line clamp to event CTA
2024-06-06 21:45:35 +02:00
Alexis
94fcd1c04e Added line clamp to event CTA 2024-06-06 21:43:32 +02:00
AlexisNP
0baf33ee19 Merge pull request #9 from AlexisNP/bugfix/add-alert-modal-for-event-deletion
Added alert modals for events
2024-06-05 23:34:07 +02:00
Alexis
85e6710560 Removed useless prop from alert dialog 2024-06-05 23:24:37 +02:00
Alexis
4972c26385 Removed resetSkeleton call
Same logic from commit b71f7745a4, as this would remove useful data from the skeleton used to splice / change the client side data
2024-06-05 23:21:33 +02:00
Alexis
05f5c5a92c Added actions / cancel to alert modals 2024-06-05 22:56:41 +02:00
Alexis
edeaaa1e19 Removed redundant data pass 2024-06-05 21:39:47 +02:00
Alexis
06b7dadf81 Added alert dialog
The shadcn cli doesn't seem to work with this one so I grabbed it from their github
2024-06-05 18:55:40 +02:00
Alexis
53024c6117 Added delete modal confirmation 2024-06-05 18:35:13 +02:00
Alexis
6749fdb46e Moved event modal states and functions to event store 2024-06-05 18:28:38 +02:00
AlexisNP
28cdffa90b Merge pull request #7 from AlexisNP/bugfix/prevent-event-stutter
Added rules for events' TransitionGroup
2024-06-05 18:06:21 +02:00
Alexis
476ca5f949 Added rules for events' TransitionGroup
This fixes the stutter that happens when an event is added / removed from the list of the current day tile
2024-06-05 18:05:14 +02:00
AlexisNP
170416a08f Merge pull request #5 from AlexisNP/bugfix/prevent-date-flash
Removed resetSkeleton on popover dismiss
2024-06-05 17:20:49 +02:00
Alexis
b71f7745a4 Removed resetSkeleton on popover dismiss
As the skeleton is already reset when it opens, it's redundant to reset it while it's not being displayed
2024-06-05 17:18:48 +02:00
Alexis
46f2b0bbed Merge branch 'features/nuxt' into dev 2024-06-05 13:32:57 +02:00
Alexis
be4eb1a7c0 Merge branch 'features/nuxt-supabase' into features/nuxt 2024-06-05 13:32:51 +02:00
Alexis
c4b5ad724c Added delete functions for events 2024-06-04 18:47:54 +02:00
Alexis
373484f17c Added transition to updating event 2024-06-04 18:31:26 +02:00
Alexis
5f6a8e46c6 Added update route for event 2024-06-04 12:01:19 +02:00
Alexis
9802266b8c Added reactivity when adding an event 2024-06-03 21:39:19 +02:00
Alexis
5332b4c9c4 Updated form component
I'll need to refactor this to a Calendar level component
2024-06-02 10:35:18 +02:00
Alexis
a92edd6215 Added post component for events
This needs RLS disabled, will work on that later, I don't like postgre
2024-06-01 11:07:07 +02:00
Alexis
f2d5277c5f Merge branch 'features/nuxt-supabase' into features/nuxt 2024-05-24 20:03:21 +02:00
Alexis
1b801504f2 Reworked relative date operations
This is the most straightforward way to deal with months that _can_ be not the same length. I'm tired
2024-05-24 18:06:58 +02:00
Alexis
c0940c4985 Working event base 2024-05-22 19:27:43 +02:00
Alexis
030e3cdc12 Fixed negative number issue 2024-05-22 19:25:28 +02:00
Alexis
f361fdeb49 Fixed issue with past dates
Some past dates would render as the wrong year packet number
2024-05-20 18:55:09 +02:00
Alexis
a1d0ae0f35 Fixed getRelativeString function
We don't need to have a precise day for display (for now) but it sure would be nice to have
2024-05-20 18:17:13 +02:00
Alexis
784b5cf421 Fixed compareDate function for flexible months
This breaks the year layout, but it's 100% caused by the props of the screen, it's not caused by the date comparison
2024-05-20 12:07:13 +02:00
Alexis
ce2dbbc8a4 Added dummy data as temp bandaid 2024-05-18 21:30:12 +02:00
Alexis
13b8be9399 This broke most things tbh, the date functions need to be reworked to account for differently formatted calendars (how idk). This would fix most issues that stem from it in other screens 2024-05-18 20:56:38 +02:00
Alexis
73e4b0e3d7 Added default date config in calendar data 2024-05-18 18:01:49 +02:00
Alexis
3de59116aa Added secondary categories to events / characters 2024-05-18 17:23:27 +02:00
Alexis
471df5e559 Fixed loading error with calendar store 2024-05-18 15:45:17 +02:00
Alexis
059edd6fc1 Added loading logic to calendar page 2024-05-17 20:50:45 +02:00
Alexis
5dd06ea3f8 Fixed errors caused by new event model 2024-05-17 17:03:56 +02:00
Alexis
67f5a270af Huge refactor for backend and client
This is barely functionnal, but at least it can display some data without crashing
Most other functionnalities other than displaying events are broken, and so are the relative date operations, they need to be fixed asap
2024-05-16 22:58:19 +02:00
Alexis
49e523485b Remodeled database and api to prepare for move 2024-05-15 17:18:17 +02:00
Alexis
3a8e8b0947 Adjusted role permissions 2024-05-14 12:29:31 +02:00
Alexis
0588cb775e Merge branch 'features/nuxt-auth' into features/nuxt 2024-05-14 12:07:11 +02:00
Alexis
f77950b5be Added auth guard middleware 2024-05-14 12:07:05 +02:00
Alexis
cd5822def4 Profile page and sidebar layouts 2024-05-14 11:53:20 +02:00
Alexis
4034104c9e Updated sidebar to be global 2024-05-14 11:21:08 +02:00
Alexis
da6b30f00c Downgraded nuxt to prevent supabase errors
The issues would happen randomly when login from google, it doesn't seem to happen again with nuxt 3.7.0 ?
2024-05-13 22:10:48 +02:00
Alexis
133404ff72 Added supabase auth to project
This design is minimalist for now, it might be extended (at least with a basic profil page or command list)
2024-05-13 20:45:52 +02:00
Alexis
df14fad98a Fixed hydration errors
Moved some code to client only (it makes sense given the localstorage stuff)
2024-05-13 16:06:57 +02:00
Alexis
b49b570fe2 Moved data to api server route 2024-05-13 14:41:28 +02:00
Alexis
bb88074215 Added back exposed colours 2024-05-12 23:50:22 +02:00
Alexis
4e9bc4137b Added back meta from old index.html 2024-05-12 22:24:17 +02:00
Alexis
5772ab7aa3 Base nuxt move after refactoring until no warnings
There still exists an issue with one Adobe led library ; @international-dates don't seem to compile their sourcemaps correctly.
I don't think this is something I can fix however, and it may require hacks and workarounds to solve from what I've gathered
2024-05-12 22:11:28 +02:00
Alexis
174b488319 Removed cypress 2024-05-12 14:11:27 +02:00
Alexis
521b3530a3 Fixed URLs guards for calendar page 2024-05-06 16:56:29 +02:00
390 changed files with 29969 additions and 10862 deletions

View File

@@ -1,35 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
overrides: [
{
files: [
'**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
'cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}',
'cypress/support/**/*.{js,ts,jsx,tsx}'
],
extends: ['plugin:cypress/recommended']
}
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
indent: ['error', 2, { SwitchCase: 1 }],
'prettier/prettier': [
'error',
{
endOfLine: 'auto'
}
],
'vue/multi-word-component-names': 'off'
}
}

46
.gitignore vendored
View File

@@ -1,30 +1,30 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
# Misc
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.fleet
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
# Local env files
.env
.env.*
!.env.example
# Supabase
.branches
.temp
.scannerwork/

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
# https://pnpm.io/npmrc#shamefully-hoist
# Fixes: WARN Failed to resolve dependency: @supabase/gotrue-js, present in 'optimizeDeps.include'
shamefully-hoist=true

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24.4.0

View File

@@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"eslint.experimental.useFlatConfig": true,
"editor.indentSize": "tabSize",
"editor.tabSize": 2,
}

View File

@@ -1,5 +1,75 @@
# Leim Tools
A congregate of tools used to visualize TTRPG elements and help content creators (mostly me for now tbh) build their worlds.
# Nuxt 3 Minimal Starter
## Timeline
Visual helper for time-related data, such as events, eras and characters. Helpful to know how old a character is while something is happening in the world, or how far two events are separated in the timeline.
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm run dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm run build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm run preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

64
app/app.vue Normal file
View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ConfigProvider } from "radix-vue"
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} — TTTools` : "TTTools";
},
meta: [
{ name: "charset", content: "UTF-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1.0" },
{ name: "author", content: "Alexis Pelé" },
{ name: "generator", content: "Nuxt" },
{ name: "msapplication-TileColor", content: "00aba9" },
{ name: "theme-color", content: "00aba9" },
{ name: "og:type", content: "tabletop-tools" },
{ name: "og:url", content: "ttt.alexcreates.fr" },
{ name: "robots", content: "noindex, nofollow"}
],
link: [
{ rel: "apple-touch-icon", sizes: "76x76", href: "/apple-touch-icon.png" },
{ rel: "icon", sizes: "32x32", type: "image/png", href: "/favicon-32x32.png" },
{ rel: "icon", sizes: "16x16", type: "image/png", href: "/favicon-16x16.png" },
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "mask-icon", href: "/safari-pinned-tab.svg", color: "#6595b4" },
]
})
const useIdFunction = () => useId()
</script>
<template>
<div class="h-screen">
<NuxtLoadingIndicator />
<ConfigProvider :use-id="useIdFunction">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</ConfigProvider>
<ClientOnly>
<UiToaster />
</ClientOnly>
</div>
</template>
<style lang="scss" scoped>
.wrapper > * {
height: 100%;
}
.has-sidebar {
position: relative;
isolation: isolate;
&::after {
content: '';
display: block;
position: fixed;
inset: 0;
z-index: 30;
}
}
</style>

331
app/assets/_colors.css Normal file
View File

@@ -0,0 +1,331 @@
:root {
--event-red-foreground: var(--color-slate-900);
--event-red-background: var(--color-red-500);
--event-orange-foreground: var(--color-slate-900);
--event-orange-background: var(--color-orange-500);
--event-amber-foreground: var(--color-slate-900);
--event-amber-background: var(--color-amber-500);
--event-yellow-foreground: var(--color-slate-900);
--event-yellow-background: var(--color-yellow-500);
--event-lime-foreground: var(--color-slate-900);
--event-lime-background: var(--color-lime-500);
--event-green-foreground: var(--color-white);
--event-green-background: var(--color-green-600);
--event-emerald-foreground: var(--color-white);
--event-emerald-background: var(--color-emerald-600);
--event-teal-foreground: var(--color-white);
--event-teal-background: var(--color-teal-600);
--event-cyan-foreground: var(--color-white);
--event-cyan-background: var(--color-cyan-600);
--event-sky-foreground: var(--color-white);
--event-sky-background: var(--color-sky-600);
--event-blue-foreground: var(--color-white);
--event-blue-background: var(--color-blue-600);
--event-indigo-foreground: var(--color-white);
--event-indigo-background: var(--color-indigo-600);
--event-violet-foreground: var(--color-white);
--event-violet-background: var(--color-violet-600);
--event-purple-foreground: var(--color-white);
--event-purple-background: var(--color-purple-600);
--event-fuchsia-foreground: var(--color-white);
--event-fuchsia-background: var(--color-fuchsia-700);
--event-pink-foreground: var(--color-white);
--event-pink-background: var(--color-pink-700);
--event-rose-foreground: var(--color-white);
--event-rose-background: var(--color-pink-700);
--event-black-foreground: var(--color-white);
--event-black-background: var(--color-stone-500);
--event-white-foreground: var(--color-slate-900);
--event-white-background: var(--color-lime-100);
}
@theme inline {
--color-event-red-foreground: var(--event-red-foreground);
--color-event-red-background: var(--event-red-background);
--color-event-orange-foreground: var(--event-orange-foreground);
--color-event-orange-background: var(--event-orange-background);
--color-event-amber-foreground: var(--event-amber-foreground);
--color-event-amber-background: var(--event-amber-background);
--color-event-yellow-foreground: var(--event-yellow-foreground);
--color-event-yellow-background: var(--event-yellow-background);
--color-event-lime-foreground: var(--event-lime-foreground);
--color-event-lime-background: var(--event-lime-background);
--color-event-green-foreground: var(--event-green-foreground);
--color-event-green-background: var(--event-green-background);
--color-event-emerald-foreground: var(--event-emerald-foreground);
--color-event-emerald-background: var(--event-emerald-background);
--color-event-teal-foreground: var(--event-teal-foreground);
--color-event-teal-background: var(--event-teal-background);
--color-event-cyan-foreground: var(--event-cyan-foreground);
--color-event-cyan-background: var(--event-cyan-background);
--color-event-sky-foreground: var(--event-sky-foreground);
--color-event-sky-background: var(--event-sky-background);
--color-event-blue-foreground: var(--event-blue-foreground);
--color-event-blue-background: var(--event-blue-background);
--color-event-indigo-foreground: var(--event-indigo-foreground);
--color-event-indigo-background: var(--event-indigo-background);
--color-event-violet-foreground: var(--event-violet-foreground);
--color-event-violet-background: var(--event-violet-background);
--color-event-purple-foreground: var(--event-purple-foreground);
--color-event-purple-background: var(--event-purple-background);
--color-event-fuchsia-foreground: var(--event-fuchsia-foreground);
--color-event-fuchsia-background: var(--event-fuchsia-background);
--color-event-pink-foreground: var(--event-pink-foreground);
--color-event-pink-background: var(--event-pink-background);
--color-event-rose-foreground: var(--event-rose-foreground);
--color-event-rose-background: var(--event-rose-background);
--color-event-black-foreground: var(--event-black-foreground);
--color-event-black-background: var(--event-black-background);
--color-event-white-foreground: var(--event-white-foreground);
--color-event-white-background: var(--event-white-background);
}
::selection {
color: var(--color-background);
background: color-mix(in srgb, var(--color-primary), var(--color-foreground) 20%)
}
.event-button,
.event-popover,
.event-callout,
.card-color {
--fg-color: var(--color-foreground);
--bg-color: var(--color-background);
transition-duration: var(--default-transition-duration);
transition-timing-function: var(--default-transition-timing-function);
}
.event-callout hr,
.card-color hr {
transition-property: border-color;
transition-duration: var(--default-transition-duration);
transition-timing-function: var(--default-transition-timing-function);
}
.event-button {
color: var(--fg-color);
background-color: var(--bg-color);
text-underline-offset: .15rem;
transition-property: background-color, color;
}
.event-button:hover {
text-decoration: underline;
background-color: color-mix(in srgb, var(--bg-color) 70%, var(--color-background));
}
.event-button.is-hidden {
background-color: transparent;
color: var(--color-foreground);
border: 1px dashed var(--bg-color);
transition-property: background-color, color, border-color;
}
.event-button.is-hidden:is(:hover, :focus-visible) {
background-color: color-mix(in srgb, var(--bg-color) 70%, var(--color-background));
color: var(--fg-color);
}
.event-popover {
background-color: color-mix(in srgb, var(--bg-color) 10%, var(--color-background));
border-color: color-mix(in srgb, var(--bg-color) 50%, var(--color-background));
transition-property: background-color, color;
hr {
border-color: var(--bg-color);
}
}
.event-callout {
position: relative;
background-color: color-mix(in srgb, var(--bg-color) 10%, var(--color-background));
border-top-right-radius: .2rem;
border-bottom-right-radius: .2rem;
transition-property: background-color, color;
hr {
border-color: var(--bg-color);
}
}
.event-callout::before {
display: block;
content: '';
position: absolute;
height: 100%;
inset-block: 0;
left: -0.5rem;
width: .5rem;
background-color: color-mix(in srgb, var(--bg-color) 70%, var(--color-background));
border-top-left-radius: .2rem;
border-bottom-left-radius: .2rem;
transition-property: background-color;
transition-duration: var(--default-transition-duration);
transition-timing-function: var(--default-transition-timing-function);
}
.event-callout:is(:hover, :focus-visible) {
background-color: color-mix(in srgb, var(--bg-color) 30%, var(--color-background));
}
.event-callout:is(:hover, :focus-visible)::before {
background-color: color-mix(in srgb, var(--bg-color) 90%, var(--color-background));
}
.event-callout.is-hidden {
border: 1px dashed var(--bg-color);
background-color: transparent;
}
.event-callout.is-hidden::before {
inset-block: -2px;
height: calc(100% + 3px);
background-color: color-mix(in srgb, var(--bg-color) 40%, var(--color-background));
}
.event-callout.is-hidden:is(:hover, :focus-visible) {
background-color: color-mix(in srgb, var(--bg-color) 5%, var(--color-background));
}
.event-callout.is-hidden:is(:hover, :focus-visible)::before {
background-color: color-mix(in srgb, var(--bg-color) 50%, var(--color-background));
}
.card-color {
border-color: color-mix(in srgb, var(--bg-color) 30%, var(--color-background));
background-color: color-mix(in srgb, var(--bg-color) 10%, var(--color-background));
hr {
border-color: color-mix(in srgb, var(--bg-color) 30%, var(--color-background))
}
}
.card-color:is(:hover, :focus-visible) {
border-color: color-mix(in srgb, var(--bg-color) 40%, var(--color-background));
background-color: color-mix(in srgb, var(--bg-color) 20%, var(--color-background));
hr {
border-color: color-mix(in srgb, var(--bg-color) 40%, var(--color-background))
}
}
.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: var(--bg-color);
border: 1px solid transparent;
}
.element-red {
--fg-color: var(--color-event-red-foreground);
--bg-color: var(--color-event-red-background);
}
.element-orange {
--fg-color: var(--color-event-orange-foreground);
--bg-color: var(--color-event-orange-background);
}
.element-amber {
--fg-color: var(--color-event-amber-foreground);
--bg-color: var(--color-event-amber-background);
}
.element-yellow {
--fg-color: var(--color-event-yellow-foreground);
--bg-color: var(--color-event-yellow-background);
}
.element-lime {
--fg-color: var(--color-event-lime-foreground);
--bg-color: var(--color-event-lime-background);
}
.element-green {
--fg-color: var(--color-event-green-foreground);
--bg-color: var(--color-event-green-background);
}
.element-emerald {
--fg-color: var(--color-event-emerald-foreground);
--bg-color: var(--color-event-emerald-background);
}
.element-teal {
--fg-color: var(--color-event-teal-foreground);
--bg-color: var(--color-event-teal-background);
}
.element-cyan {
--fg-color: var(--color-event-cyan-foreground);
--bg-color: var(--color-event-cyan-background);
}
.element-sky {
--fg-color: var(--color-event-sky-foreground);
--bg-color: var(--color-event-sky-background);
}
.element-blue {
--fg-color: var(--color-event-blue-foreground);
--bg-color: var(--color-event-blue-background);
}
.element-indigo {
--fg-color: var(--color-event-indigo-foreground);
--bg-color: var(--color-event-indigo-background);
}
.element-violet {
--fg-color: var(--color-event-violet-foreground);
--bg-color: var(--color-event-violet-background);
}
.element-purple {
--fg-color: var(--color-event-purple-foreground);
--bg-color: var(--color-event-purple-background);
}
.element-fuchsia {
--fg-color: var(--color-event-fuchsia-foreground);
--bg-color: var(--color-event-fuchsia-background);
}
.element-pink {
--fg-color: var(--color-event-pink-foreground);
--bg-color: var(--color-event-pink-background);
}
.element-rose {
--fg-color: var(--color-event-rose-foreground);
--bg-color: var(--color-event-rose-background);
}
.element-black {
--fg-color: var(--color-event-black-foreground);
--bg-color: var(--color-event-black-background);
}
.element-white {
--fg-color: var(--color-event-white-foreground);
--bg-color: var(--color-event-white-background);
}

66
app/assets/_content.css Normal file
View File

@@ -0,0 +1,66 @@
.content {
> * + * {
margin-top: .5em;
@variant md {
margin-top: .75rem;
}
}
h1 {
font-size: var(--text-3xl);
font-weight: var(--font-weight-bold);
@variant md {
font-size: var(--text-4xl);
}
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
@variant md {
font-size: var(--text-2xl);
}
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-weight-bold);
@variant md {
font-size: var(--text-xl);
}
}
h4 {
font-size: var(--text-md);
font-weight: var(--font-weight-bold);
@variant md {
font-size: var(--text-lg);
}
}
p, ul, ol {
font-size: var(--text-sm);
}
/* Normal link */
p a:not([class]) {
color: var(--color-primary);
text-underline-offset: .25rem;
&:hover {
color: var(--color-accent);
text-decoration: underline;
}
}
ul {
list-style-type: square;
padding-inline-start: 1.5rem;
}
hr {
margin-block: 2rem;
}
}

View File

@@ -0,0 +1,10 @@
@theme inline {
--text-2xs: 0.7rem;
--text-2xs--line-height: 0.75rem;
}
@layer utilities {
.flow > * + * {
margin-top: 1em;
}
}

63
app/assets/main.css Normal file
View File

@@ -0,0 +1,63 @@
@import 'tailwindcss';
@config '../../tailwind.config.js';
@import "./theme.css" layer(theme);
@import "./_typography.css" layer(theme);
@import "./_typography.css" layer(utilities);
@import "./_colors.css" layer(theme);
@import "./_colors.css" layer(utilities);
@import "./_content.css";
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-border, currentcolor);
}
}
body {
color: var(--color-foreground);
background-color: var(--color-background);
}
.fade-enter-active,
.fade-leave-active,
.fade-group-enter-active,
.fade-group-leave-active {
transition: all .5s ease;
}
.fade-delay-enter-active,
.fade-delay-leave-active,
.fade-group-delay-enter-active,
.fade-group-delay-leave-active {
transition: all .5s ease 1s;
}
.fade-enter-from,
.fade-leave-to,
.fade-delay-enter-from,
.fade-delay-leave-to,
.fade-group-enter-from,
.fade-group-leave-to,
.fade-group-delay-enter-from,
.fade-group-delay-leave-to {
opacity: 0;
visibility: hidden;
}
.fade-group-leave-active {
position: absolute;
}

111
app/assets/theme.css Normal file
View File

@@ -0,0 +1,111 @@
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--muted: hsl(210 40% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%);
--popover-border: rgb(175, 224, 200);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--primary: rgb(101, 230, 166);
--primary-foreground: hsl(222.2 84% 4.9%);
--secondary: hsl(210 50% 95%);
--secondary-foreground: hsl(222.2 84% 4.9%);
--accent: hsl(11, 100%, 86%);
--accent-foreground: hsl(222.2 84% 4.9%);
--destructive: hsl(0, 100%, 63%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(156 72% 67%);
--success-muted: hsl(156 72% 90%);
--success-foreground: hsl(222.2 84% 4.9%);
--ring: hsl(222.2 84% 4.9%);
--radius: 0.5rem;
}
.dark {
--background: hsl(180 0 4.9%);
--foreground: hsl(210 40% 98%);
--muted: hsl(0, 0%, 12%);
--muted-foreground: hsl(215 20.2% 65.1%);
--popover: hsl(165, 47%, 5%);
--popover-foreground: hsl(210 40% 98%);
--popover-border: hsl(165, 47%, 20%);
--card: hsl(180 0 4.9%);
--card-foreground: hsl(210 40% 98%);
--border: hsl(0, 0%, 22.5%);
--input: hsl(165, 47%, 20%);
--primary: rgb(101, 230, 166);
--primary-foreground: hsl(180 0 4.9%);
--secondary: hsl(0, 0%, 22.5%);
--secondary-foreground: hsl(210 40% 98%);
--accent: hsl(11, 87%, 69%);
--accent-foreground: hsl(222.2 84% 4.9%);
--destructive: hsl(0, 62%, 49%);
--destructive-foreground: hsl(210 40% 98%);
--success: hsl(142 72% 29%);
--success-muted: hsl(142 72% 10%);
--success-foreground: hsl(210 40% 98%);
--ring: hsl(212.7 26.8% 83.9%);
--radius: 0.5rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-popover-border: var(--popover-border);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-muted: var(--success-muted);
--color-success-foreground: var(--success-foreground);
--color-ring: var(--ring);
}

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { computed, type Component, type ComputedRef } from "vue"
// import { PhMagnifyingGlass } from '@phosphor-icons/vue'
import MonthlyLayout from "./state/monthly/Layout.vue"
import CenturyLayout from "./state/centennially/Layout.vue"
import DecadeLayout from "./state/decennially/Layout.vue"
import YearLayout from "./state/yearly/Layout.vue"
const { currentConfig, jumpToDate, selectedDate } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
const currentViewComponent: ComputedRef<Component> = computed<Component>(() => {
switch (currentConfig.viewType) {
case "month":
return MonthlyLayout
case "year":
return YearLayout
case "decade":
return DecadeLayout
case "century":
default:
return CenturyLayout
}
})
onMounted(() => {
jumpToDate(selectedDate)
})
</script>
<template>
<div class="h-full w-full relative">
<div class="h-full grid grid-rows-[auto_1fr]">
<CalendarMenu />
<component :is="currentViewComponent" />
<CalendarSearch />
<LazyCalendarDialogCategories v-if="!isReadOnly" />
<LazyCalendarDialogUpdateEvent v-if="!isReadOnly" />
<LazyCalendarDialogDeleteEvent v-if="!isReadOnly" />
</div>
</div>
</template>
<style lang="scss" scoped>
.screen-move, /* apply transition to moving elements */
.screen-enter-active,
.screen-leave-active {
transition: all .4s ease-out;
}
.screen-enter-from,
.screen-leave-to {
opacity: 0;
}
/* ensure leaving items are taken out of layout flow so that moving
animations can be calculated correctly. */
.screen-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { PhTag } from "@phosphor-icons/vue"
const { toggleCategoriesModal } = useCalendar()
const { isReadOnly } = storeToRefs(useCalendar())
</script>
<template>
<ClientOnly>
<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>
</ClientOnly>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { computed } from "vue"
import { PhMapPin } from "@phosphor-icons/vue"
import { breakpointsTailwind } from "@vueuse/core"
const { defaultDate, getFormattedDateTitle, getRelativeString, getDifferenceInDays } = useCalendar()
const { selectedDate } = storeToRefs(useCalendar())
const breakpoints = useBreakpoints(
breakpointsTailwind
)
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="grid md:flex md:gap-2 items-center">
<h1 class="text-lg md:text-2xl max-md:leading-tight font-bold flex items-center gap-1">
<PhMapPin
:size="breakpoints.md.value ? 26 : 18"
class="max-md:hidden"
weight="light"
/>
{{ mainDateTitle }}
</h1>
<h2 v-if="!isToday" class="text-sm max-md:leading-tight md:text-xl italic opacity-75">
<span class="max-md:hidden"></span> {{ dateDifference }}
</h2>
</div>
<template #fallback>
<div class="flex items-center gap-1">
<UiSkeleton class="h-8 w-64 max-md:max-w-full" />
<UiSkeleton class="max-md:hidden h-6 w-28" />
</div>
</template>
</ClientOnly>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { breakpointsTailwind } from "@vueuse/core"
import { cn } from "@/lib/utils"
import { useCalendar } from "@/stores/CalendarStore"
import { PhCalendarBlank, PhCheckCircle, PhDotsThreeVertical, PhGear, PhTag } from "@phosphor-icons/vue"
const breakpoints = useBreakpoints(
breakpointsTailwind
)
const { currentConfig, viewTypeOptions, getViewTypeTitle, setViewType, toggleCategoriesModal } = useCalendar()
const viewTypeTitle = computed(() => getViewTypeTitle(currentConfig.viewType))
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton variant="secondary" size="icon">
<ClientOnly>
<PhGear v-if="breakpoints.md.value" size="20" weight="fill" />
<PhDotsThreeVertical v-else size="26" weight="bold" />
</ClientOnly>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent :side="'bottom'" :align="'start'" :side-offset="10" :align-offset="25" :collision-padding="20" class="text-right">
<UiDropdownMenuArrow />
<UiDropdownMenuLabel>
{{ $t('entity.calendar.seeOptions') }}
</UiDropdownMenuLabel>
<UiDropdownMenuItem
v-if="!breakpoints.md.value"
class="flex gap-[1ch] justify-end items-center"
@click="toggleCategoriesModal(true)"
>
{{ $t('entity.calendar.seeCategories') }}
<PhTag size="18" />
</UiDropdownMenuItem>
<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,103 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
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 cardRef = ref(null)
const isCardHovered = useElementHover(cardRef)
const { focused: isCardFocused } = useFocusWithin(cardRef)
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}`)
const hasActions = computed(() => isOwner.value && props.showActions)
</script>
<template>
<UiCard
ref="cardRef"
class="w-full h-full flex flex-col"
:link="calendarLink"
:class="cn(
calendar.color ? `group card-color element-${calendar.color}` : '',
)"
>
<UiCardHeader class="gap-4">
<UiCardTitle
class="text-xl"
:class="cn({
'pr-18': hasActions
})"
>
{{ 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="hasActions && (isCardHovered || isCardFocused)"
class="flex gap-2 absolute top-4 right-4 z-20"
>
<Transition name="fade" appear>
<UiButton size="icon" variant="outline" class="size-8 border-foreground/20 group-hover:border-foreground/30 hover:text-background hover:border-foreground hover:bg-foreground" @click="emit('on-edit')">
<PhPencil size="15" weight="fill" />
</UiButton>
</Transition>
<Transition name="fade" appear>
<UiButton size="icon" variant="outline" class="size-8 border-foreground/25 group-hover:border-foreground/40 hover:text-background hover:border-rose-500 hover:bg-rose-500" @click="emit('on-delete')">
<PhTrash size="15" weight="fill" />
</UiButton>
</Transition>
</div>
</UiCardContent>
<hr>
<UiCardFooter class="border-t-0">
<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,28 @@
<script lang="ts" setup>
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
const { revealAdvancedSearch } = useCalendar()
const breakpoints = useBreakpoints(
breakpointsTailwind
)
</script>
<template>
<ClientOnly>
<Transition name="fade" appear>
<UiButton
:search-slash="breakpoints.lg.value"
:size="breakpoints.lg.value ? 'default' : 'icon'"
@click="revealAdvancedSearch()"
>
<PhMagnifyingGlass size="20" weight="light" />
<span v-if="breakpoints.lg.value">
{{ $t('entity.advancedSearch.title') }}
</span>
</UiButton>
</Transition>
</ClientOnly>
</template>

View File

@@ -0,0 +1,3 @@
export const searchUnifier = new RegExp(/[^a-zA-Z0-9\-']/g)
export type SearchMode = "characters" | "events" | undefined

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,70 @@
<script setup lang="ts">
import type { Category } from "@@/models/Category";
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
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: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"
@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-primary/0 hover:bg-primary/15"
@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-hidden 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-border 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<{
"on-delete-category": [payload: Category]
}>()
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-primary/10 hover:bg-primary/20': currentMode === 'edit' },
{ 'bg-primary/0 hover:bg-primary/15': 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-hidden 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(`element-${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="default"
size="icon"
class="w-6 h-6 rounded-full"
:title="$t('ui.actions.edit')"
@click="submitUpdate"
>
<PhCheck size="14" weight="bold" />
</UiButton>
</li>
<li v-else-if="rowHovered">
<UiButton
variant="destructive"
size="icon"
class="w-6 h-6 rounded-full"
: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

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { PhX } from "@phosphor-icons/vue";
import type { World } from "@@/models/World";
defineProps<{
world: World,
modalState?: boolean
}>()
const calendarSkeletonName = ref<string>("")
function onChangedName(newName: string) {
calendarSkeletonName.value = newName
}
const emit = defineEmits(["on-close"])
function handleClose() {
emit("on-close")
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.createDialog.title') }}
</span>
</span>
</UiAlertDialogTitle>
<UiButton size="icon" variant="ghost" class="absolute top-4 right-4" title="Fermer la fenêtre" @click="handleClose">
<PhX size="20" />
</UiButton>
<CalendarFormCreate v-if="world.id" :world-id="world.id" @on-changed-name="onChangedName" @on-close="handleClose" />
</UiAlertDialogContent>
</UiAlertDialog>
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date";
const { eventSkeleton, operationInProgress, currentRPGDate } = 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()
}
// If the date changes, the popover should close
// Only for months for now (because it's the only view)
watch(currentRPGDate, () => {
if (!popoverOpen.value) return
handleClosing()
})
</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"
@escape-key-down.prevent="handleClosing"
@pointer-down-outside.prevent="handleClosing"
>
<CalendarFormCreateEvent @event-created="handleClosing" />
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,103 @@
<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()
const { t } = useI18n()
const props = defineProps<{
modalState: boolean,
calendar: Calendar | null
}>()
const isLoading = ref<boolean>(false)
const emit = defineEmits(["on-close"])
async function handleAction(): Promise<void> {
if (isLoading.value) return
if (!props.calendar) return
isLoading.value = true
const { error } = await tryCatch($fetch(`/api/calendars/${props.calendar.id}`, { method: "DELETE" }))
if (error) {
toast({
title: error.message,
variant: "destructive"
})
isLoading.value = false
return
}
toast({
title: t("entity.calendar.deletedToast.title", { calendar: props.calendar.name }),
variant: "success",
duration: ToastLifetime.SHORT
})
emit("on-close")
isLoading.value = false
}
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing() {
if (!isLoading.value) {
emit("on-close")
}
}
</script>
<template>
<UiAlertDialog :open="modalState">
<UiAlertDialogContent
:disable-outside-pointer-events="true"
:trap-focus="true"
class="min-w-96"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
@close-auto-focus="(e) => e.preventDefault()"
>
<UiAlertDialogTitle>
{{ $t('entity.calendar.deleteDialog.title') }}
</UiAlertDialogTitle>
<UiAlertDialogDescription>
{{ $t('entity.calendar.deleteDialog.subtitle') }}
</UiAlertDialogDescription>
<form @submit.prevent="handleAction">
<footer class="flex gap-2 justify-between">
<UiButton type="button" size="sm" variant="outline" @click="handleClosing">
{{ $t('ui.action.back') }}
</UiButton>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton v-if="calendar" size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('entity.deleteOne', { entity: calendar?.name }) }}
</UiButton>
</div>
</footer>
</form>
</UiAlertDialogContent>
</UiAlertDialog>
</template>

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"
@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,70 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { PhPlus } from "@phosphor-icons/vue";
import { VisuallyHidden } from "radix-vue";
const isDialogOpen = ref<boolean>(false);
const { resetSkeleton } = useCalendar();
// Watch the popover state
watch(isDialogOpen, (hasOpened, _o) => {
if (hasOpened) {
resetSkeleton()
}
})
// Toggles the dialog
function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value;
};
/**
* Prevents the modal from closing if's still loading
*/
function handleClosing() {
setTimeout(() => resetSkeleton(), 100)
}
const breakpoints = useBreakpoints(
breakpointsTailwind
)
</script>
<template>
<ClientOnly>
<Transition name="fade" appear>
<UiButton
class="max-md:fixed max-md:bottom-8 max-md:right-8 max-md:z-50 max-md:size-14 max-md:rounded-xl"
:size="breakpoints.md.value ? 'default' : 'icon'"
@click="toggleDialog"
>
<PhPlus :size="breakpoints.md.value ? 18 : 24" weight="bold" />
<strong v-if="breakpoints.md.value" class="font-semibold">
{{ $t("entity.calendar.event.newEvent") }}
</strong>
</UiButton>
</Transition>
</ClientOnly>
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent
class="max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
:trap-focus="true"
@escape-key-down="handleClosing"
@pointer-down-outside="handleClosing"
>
<UiDialogTitle class="max-md:mb-8">
{{ $t("entity.calendar.event.addSingle") }}
</UiDialogTitle>
<VisuallyHidden>
<UiDialogDescription>
{{ $t("entity.calendar.event.addSingleDescription") }}
</UiDialogDescription>
</VisuallyHidden>
<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 md:min-w-96 max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside.prevent
>
<header class="pl-8 grid gap-y-2 max-md:mb-8">
<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

@@ -0,0 +1,225 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { useCalendar } from "@/stores/CalendarStore"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import {
PhHourglassMedium,
PhAlarm,
PhHourglassHigh,
PhHourglassLow,
PhMapPinArea,
PhDotsThreeOutlineVertical,
PhEye
} from "@phosphor-icons/vue"
import type { CollisionPadding } from "@@/models/Popover"
const { defaultDate, getFormattedDateTitle, jumpToDate, getRelativeString, revealEditEventModal, revealDeleteEventModal } = useCalendar()
const { lastActiveEvent, isReadOnly } = storeToRefs(useCalendar())
const props = defineProps<{
event: CalendarEvent
spansMultipleDays: boolean
isStartEvent?: boolean
isEndEvent?: boolean
}>()
const breakpoints = useBreakpoints(
breakpointsTailwind
)
const collisionPadLg: CollisionPadding = {
top: 50,
bottom: 50,
left: 50,
right: 50,
}
const collisionPad: CollisionPadding = {
top: 80,
bottom: 80,
left: 12.5,
right: 12.5,
}
// Ref for the popover
const eventDetails = ref<HTMLElement>()
const emit = defineEmits(["query:close-popover"])
const dateDifference = computed<string>(() => getRelativeString(defaultDate, props.event.startDate))
const dateDuration = computed<string | null>(() => props.event.endDate ? getRelativeString(props.event.startDate, props.event.endDate, "compact") : null)
function handleJumpToDate(date: RPGDate) {
jumpToDate(date)
emit("query:close-popover")
}
/**
* Edit event
*/
const commandMenuOpened = ref(false)
function deployEditModal() {
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealEditEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
/**
* Confirm event deletion
*/
function deployDeleteModal() {
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealDeleteEventModal()
commandMenuOpened.value = false
emit("query:close-popover")
}
</script>
<template>
<UiPopoverContent
class="max-w-full w-96 event-popover"
:align="'center'"
:align-offset="breakpoints.lg.value ? 50 : 25"
:side-offset="8"
:side="breakpoints.lg.value ? 'left' : 'top'"
:collision-padding="breakpoints.lg.value ? collisionPadLg : collisionPad"
:class="cn(
event.category ? `element-${event.category.color}` : '',
)"
@focus-outside.prevent
>
<div ref="eventDetails" class="grid gap-1">
<header class="pr-12">
<div class="mb-2 max-h-24 overflow-y-auto text-lg font-semibold">
{{ event.title }}
</div>
<div class="mb-1">
<template v-if="!event.endDate">
<p class="font-semibold">{{ getFormattedDateTitle(event.startDate, true) }}</p>
</template>
<template v-else>
<p class="font-semibold">
{{
$t('entity.calendar.date.fromTo',
{
startDate: getFormattedDateTitle(event.startDate, true),
endDate: getFormattedDateTitle(event.endDate, true)
}
)
}}
</p>
</template>
</div>
</header>
<div class="mb-1 space-y-1">
<template v-if="event.location">
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
<div>
<PhMapPinArea size="16" weight="fill" />
</div>
<p class="max-h-12 overflow-y-auto">{{ event.location }}</p>
</div>
</template>
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
<div>
<PhAlarm size="16" weight="fill" />
</div>
<p>{{ dateDifference }}</p>
</div>
<template v-if="dateDuration">
<div class="text-sm italic dark:opacity-75 flex items-center gap-1">
<div>
<PhHourglassMedium size="16" weight="fill" />
</div>
<p>{{ $t('entity.calendar.date.while', { duration: dateDuration } )}}</p>
</div>
</template>
</div>
<template v-if="event.category || event.secondaryCategories">
<ul class="flex gap-1">
<li v-if="event.category">
<UiBadge class="mix-blend-luminosity font-bold bg-gray-400 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ event.category?.name }}
</UiBadge>
</li>
<li v-for="cat in event.secondaryCategories" :key="cat.id">
<UiBadge class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ cat.name }}
</UiBadge>
</li>
</ul>
</template>
<template v-if="event.description">
<hr class="border-border mt-2" >
<div class="max-h-48 overflow-y-auto mt-2 text-sm opacity-80">
<div class="content-editor" v-html="event.description" />
</div>
</template>
</div>
<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">
<PhDotsThreeOutlineVertical size="20" weight="fill" />
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-fit p-0" align="start" side="right" :collision-padding="20" >
<UiCommand>
<UiCommandList>
<UiCommandGroup>
<UiCommandItem value="edit-event" @select="deployEditModal"> {{ $t('ui.action.edit') }} </UiCommandItem>
<UiCommandItem value="delete-event" @select="deployDeleteModal"> {{ $t('ui.action.delete') }} </UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</menu>
<nav v-if="event.startDate && event.endDate" class="mt-2 flex gap-2">
<UiBadge as-child>
<button
class="flex gap-1 cursor-pointer"
:title="$t('entity.calendar.event.gotoStart')"
@click="handleJumpToDate(event.startDate!)"
>
<PhHourglassHigh size="16" weight="fill" /> {{ $t('entity.calendar.event.isStart') }}
</button>
</UiBadge>
<UiBadge as-child>
<button
class="flex gap-1 cursor-pointer"
:title="$t('entity.calendar.event.gotoEnd')"
@click="handleJumpToDate(event.endDate!)"
>
<PhHourglassLow size="16" weight="fill" /> {{ $t('entity.calendar.event.isEnd') }}
</button>
</UiBadge>
</nav>
<UiTooltipProvider v-if="event.hidden" :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiBadge class="absolute -top-2 right-2 flex gap-1">
<PhEye size="16" weight="fill" /> {{ $t('entity.calendar.event.isHidden') }}
</UiBadge>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.event.hiddenTooltip') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</UiPopoverContent>
</template>

View File

@@ -0,0 +1,111 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { ja } from "zod/v4/locales";
const props = defineProps<{
event: CalendarEvent
tileDate: RPGDate
}>()
const buttonRef = useTemplateRef<HTMLButtonElement>('buttonRef')
const { areDatesIdentical, revealEditEventModal, revealDeleteEventModal, resetSkeleton } = useCalendar()
const { lastActiveEvent, eventSkeleton, isDraggingEvent } = storeToRefs(useCalendar())
const spansMultipleDays = computed(() => Boolean(props.event.startDate && props.event.endDate))
const isStartEvent = computed(() => spansMultipleDays.value && areDatesIdentical(props.tileDate, props.event.startDate))
const isEndEvent = computed(() => spansMultipleDays.value && props.event.endDate && areDatesIdentical(props.tileDate, props.event.endDate))
const titleCharLimit = 50;
const eventTitle = computed<string>(() => props.event.title.length <= titleCharLimit ? props.event.title : `${props.event.title.slice(0, titleCharLimit)}`)
// Popover code
const isPopoverDetailsOpen = ref<boolean>(false)
function handleDoubleClick() {
isPopoverDetailsOpen.value = false
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealEditEventModal()
}
function handleDelete() {
isPopoverDetailsOpen.value = false
lastActiveEvent.value = structuredClone(toRaw(props.event))
revealDeleteEventModal()
}
function handleClosePopover() {
isPopoverDetailsOpen.value = false
}
onMounted(() => {
// Listen for keydown events
window.addEventListener("keydown", (e: KeyboardEvent) => {
// If the popover isn't opened, this is not the event we're trying to delete, so return
if (!isPopoverDetailsOpen.value) return
// If the key isn't the delete one, return
if (e.key !== "Delete") return
handleDelete()
})
})
// Draggable logic
const isTileDragging = shallowRef(false)
function handleDragStart() {
isTileDragging.value = true
isDraggingEvent.value = true
eventSkeleton.value = structuredClone(toRaw(props.event))
handleClosePopover()
}
function handleDragEnd() {
isTileDragging.value = false
isDraggingEvent.value = false
}
</script>
<template>
<UiPopover v-model:open="isPopoverDetailsOpen">
<UiPopoverTrigger as-child>
<button
ref="buttonRef"
class="event-button text-2xs md:text-xs px-[5px] py-[5px] md:px-2 md:py-1 block w-full text-left rounded-sm transition-opacity outline-offset-1 cursor-pointer"
:draggable="event.endDate ? 'false' : 'true'"
:class="
cn(
`element-${event.category?.color}`,
{
'is-hidden': event.hidden,
'rounded-r-none': isStartEvent,
'rounded-l-none': isEndEvent,
},
{
'opacity-60': isTileDragging,
'opacity-100': !isTileDragging
}
)
"
@dblclick="handleDoubleClick"
@keydown.delete="handleDelete"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
>
<div class="line-clamp-2 [overflow-wrap:anywhere] hyphens-auto">
{{ eventTitle }}
</div>
</button>
</UiPopoverTrigger>
<CalendarEventDetails
:event
:spans-multiple-days
:is-start-event
:is-end-event
@query:close-popover="handleClosePopover"
/>
</UiPopover>
</template>

View File

@@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { Calendar } from "@@/models/CalendarConfig";
import { PhAlarm, PhCalendarDots, PhCircleNotch, PhWrench } from "@phosphor-icons/vue";
const props = defineProps<{
worldId: number
}>()
const defaultSkeleton: Calendar = { name: "", today: { day: 1, month: 0, year: 0 }, months: [], events: [], categories: [], state: "draft", color: "white" }
const calendarSkeleton = ref<Calendar>({ ...defaultSkeleton })
onMounted(() => {
calendarSkeleton.value = { ...defaultSkeleton }
})
type FormTabs = "global" | "months" | "today"
const activeTab = ref<FormTabs>("global")
const newCalendarName = shallowRef<HTMLInputElement>()
useFocus(newCalendarName, { initialValue: true })
/**
* === Current date ===
*/
// If the months data change, just reset today's month
// This is a failsafe mainly because of 1) month positions and 2) month names
watch(calendarSkeleton.value.months, () => {
calendarSkeleton.value.today.month = 0
}, { deep: true })
/**
* === Form Validation ===
*/
/** Whether the skeleton has valid month data */
const validSkeletonMonths = computed(() => calendarSkeleton.value.months.length > 0)
/** Whether the skeleton has a valid name */
const validSkeletonGeneral = computed(() => calendarSkeleton.value.name)
/** Whether all the data checks above are a-ok */
const validSkeleton = computed(() => validSkeletonGeneral.value && validSkeletonMonths.value)
/** Send the data to the store for validation */
const isCreatingCalendar = ref<boolean>(false)
async function handleSubmit() {
isCreatingCalendar.value = true
const { error } = await tryCatch($fetch("/api/calendars/create", { method: "POST", body: { ...calendarSkeleton.value, worldId: props.worldId } }))
if (error) {
console.log(error.message)
isCreatingCalendar.value = false
return
}
emit("on-close")
isCreatingCalendar.value = false
}
/**
* Watch for name changes to display above
*/
const emit = defineEmits<{
"on-changed-name": [calendarName: string]
"on-close": []
}>()
/** Hook to emit a debounced event for the changed skeleton name */
const handleNameChange = useDebounceFn(() => {
emit("on-changed-name", calendarSkeleton.value.name)
}, 400)
function handleFormCancel() {
emit("on-close")
}
</script>
<template>
<template v-if="calendarSkeleton">
<form class="h-full grid grid-rows-[1fr_auto]" @submit.prevent="handleSubmit">
<UiTabs v-model:model-value="activeTab">
<UiTabsList class="grid w-full grid-cols-3 mb-4">
<UiTabsTrigger value="global" class="font-bold">
<div class="flex items-center gap-1">
<PhWrench size="18" weight="fill" />
{{ $t('entity.calendar.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
ref="newCalendarName"
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-hidden focus-visible:border-primary invalid:border-destructive"
@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 || isCreatingCalendar">
<Transition name="fade">
<PhCircleNotch v-if="isCreatingCalendar" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>
</template>

View File

@@ -0,0 +1,178 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date";
import { PhAlarm, PhCircleNotch, PhEye, PhEyeClosed, PhMapPinArea, PhTag } from "@phosphor-icons/vue"
const { t } = useI18n()
const emit = defineEmits(["event-created"])
const props = defineProps<{
date?: RPGDate
btnClass?: string
}>()
const { eventSkeleton } = storeToRefs(useCalendar())
const { submitSkeleton, cancelLatestRequest } = useCalendar()
const newEventTitle = shallowRef<HTMLInputElement>()
useFocus(newEventTitle, { initialValue: true })
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleSubmit() {
// Prevent form submission if already loading
if (isLoading.value) return
isLoading.value = true
const { error } = await tryCatch(submitSkeleton())
if (error) {
formErrors.message = error.message
isLoading.value = false
return
}
emit("event-created")
isLoading.value = false
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script>
<template>
<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"
required
:placeholder="$t('entity.calendar.event.title')"
:maxlength="120"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,120}"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
<div class="col-span-2 my-2 pl-8">
<InputEditor
disable-blocks
v-model="eventSkeleton.description"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="props.date"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="props.date"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:maxlength="160"
pattern="([A-Za-zÀ-ÖØ-öø-ÿ0-9\s\&\-\~]+){3,160}"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiCheckbox id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
{{ $t('entity.calendar.event.hiddenLabel') }}
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 pl-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</div>
</div>
</form>
</template>

View File

@@ -0,0 +1,84 @@
<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
const { error } = await tryCatch(deleteCategoryFromSkeleton())
if (error) {
formErrors.message = error.message
isLoading.value = false
return
}
toast({
title: t("entity.category.deletedToast.title", { category: categoryName }),
variant: "success",
duration: ToastLifetime.MEDIUM
})
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

@@ -0,0 +1,87 @@
<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 } = storeToRefs(useCalendar())
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) return
isLoading.value = true
const eventTitle = eventSkeleton.value.title
const { error } = await tryCatch(deleteEventFromSkeleton())
if (error) {
formErrors.message = error.message
isLoading.value = false
return
}
isDeleteEventModalOpen.value = false
resetSkeleton()
toast({
title: t("entity.calendar.event.deletedToast.title", { event: eventTitle }),
variant: "success",
duration: ToastLifetime.MEDIUM
})
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="() => isDeleteEventModalOpen = false">
{{ $t('ui.action.back') }}
</UiButton>
<div class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" variant="destructive" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.delete') }}
</UiButton>
</div>
</footer>
</form>
</template>

View File

@@ -0,0 +1,135 @@
<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")
const newCalendarName = shallowRef<HTMLInputElement>()
useFocus(newCalendarName, { initialValue: true })
/**
* === Form Validation ===
*/
/** Whether the skeleton has valid month data */
const validSkeletonMonths = computed(() => calendarSkeleton.value.months.length > 0)
/** Whether the skeleton has a valid name */
const validSkeletonGeneral = computed(() => calendarSkeleton.value.name)
/** Whether all the data checks above are a-ok */
const validSkeleton = computed(() => validSkeletonGeneral.value && validSkeletonMonths.value)
/** Send the data to the store for validation */
const isUpdatingCalendar = ref<boolean>(false)
async function handleSubmit() {
isUpdatingCalendar.value = true
const fetchBody = { ...calendarSkeleton.value, worldId: props.world?.id }
const { error } = await tryCatch(
$fetch(`/api/calendars/${calendarSkeleton.value.id}`, { method: "PATCH", body: fetchBody })
)
if (error) {
console.log(error.message)
isUpdatingCalendar.value = false
return
}
emit("on-close")
isUpdatingCalendar.value = false
}
/**
* Watch for name changes to display above
*/
const emit = defineEmits<{
"on-changed-name": [calendarName: string]
"on-close": []
}>()
/** 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>
</UiTabsList>
<UiTabsContent value="global" class="grid gap-4">
<input
ref="newCalendarName"
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-hidden focus-visible:border-primary invalid:border-destructive"
@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>
</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

@@ -0,0 +1,199 @@
<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 } = storeToRefs(useCalendar())
const { toast } = useToast()
const { t } = useI18n()
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({
message: null
})
async function handleAction() {
if (isLoading.value) return
isLoading.value = true
const { error } = await tryCatch(updateEventFromSkeleton())
if (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiError = (error as any).data as APIError
apiError.data.errors.forEach((error) => {
toast({
title: t("entity.calendar.event.editErrors.toastTitle"),
variant: "destructive",
description: t(`entity.calendar.event.editErrors.${error.path[1]}_${error.code}`),
duration: ToastLifetime.MEDIUM,
})
})
isLoading.value = false
return
}
emit("event-updated")
toast({
title: t("entity.calendar.event.updatedToast.title", { event: eventSkeleton.value.title }),
variant: "success",
duration: ToastLifetime.SHORT
})
isLoading.value = false
resetSkeleton()
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script>
<template>
<form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-3">
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhPencilSimpleLine size="20" weight="fill" />
<div class="grow">
<input
id="new-event-title"
v-model="eventSkeleton.title"
type="text"
name="new-event-title"
required
:placeholder="$t('entity.calendar.event.title')"
:minlength="3"
:maxlength="120"
class="w-full -my-1 py-1 -mx-1 px-1 text-lg border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.title') }}
</div>
</div>
</div>
</div>
<div class="col-span-2 my-2 ml-8">
<InputEditor
disable-blocks
v-model="eventSkeleton.description"
/>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.description') }}
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhAlarm size="18" weight="fill" />
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.startDate"
:placeholder="$t('entity.calendar.date.start')"
:initial-date="lastActiveEvent?.startDate"
:required="true"
/>
<span></span>
<CalendarInputRPGDate
v-model:model-value="eventSkeleton.endDate"
:placeholder="$t('entity.calendar.date.end')"
:initial-date="lastActiveEvent?.endDate"
/>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhTag size="18" weight="fill" />
<div class="w-1/2">
<CalendarInputEventCategory
v-model="eventSkeleton.category"
:placeholder="$t('entity.category.addPrimary')"
/>
</div>
</div>
</div>
<div class="col-span-2 mb-2">
<div class="flex items-center gap-4">
<PhMapPinArea size="18" weight="fill" />
<div class="grow">
<input
id="new-event-location"
v-model="eventSkeleton.location"
type="text"
name="new-event-location"
:placeholder="$t('entity.calendar.event.addLocation')"
:minlength="3"
:maxlength="160"
class="w-full -my-1 py-2 px-2 text-sm border-b-[1px] bg-transparent focus-visible:outline-hidden focus-visible:border-primary invalid:border-destructive"
>
<div class="mt-2 mb-1 text-xs opacity-50">
{{ t('entity.calendar.event.patterns.location') }}
</div>
</div>
</div>
</div>
<div class="col-span-2">
<div class="flex items-center gap-4">
<PhEye v-if="!eventSkeleton.hidden" size="18" weight="fill" />
<PhEyeClosed v-else size="18" />
<div class="flex items-center gap-x-2">
<UiCheckbox id="new-event-visibility" v-model:checked="eventSkeleton.hidden" />
<UiLabel for="new-event-visibility">
{{ $t('entity.calendar.event.hiddenLabel') }}
</UiLabel>
</div>
</div>
</div>
<div class="text-red-500 ml-8">
<span class="text-sm">
{{ formErrors.message }}
</span>
</div>
</div>
<footer class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
{{ $t('ui.action.cancel') }}
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
{{ $t('ui.action.save') }}
</UiButton>
</footer>
</form>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { Category } from "@@/models/Category";
import { PhCaretDown, PhCheck } from "@phosphor-icons/vue";
const isPopoverOpen = ref<boolean>(false)
const props = defineProps<{
placeholder?: string
}>()
const model = defineModel<Category[]>({ default: [] })
const modelBuffer = ref<Category[]>([])
watch(modelBuffer.value, () => {
model.value = [ ...modelBuffer.value ]
})
const { categories: availableCategories } = useCalendar()
const searchTerm = ref<string>("")
const filteredCategories = computed(() =>
searchTerm.value === ""
? availableCategories
: availableCategories.filter((category) => {
return category.name.toLowerCase().includes(searchTerm.value.toLowerCase())
})
)
</script>
<template>
<UiPopover v-model:open="isPopoverOpen">
<UiPopoverTrigger as-child>
<UiButton
variant="outline"
role="combobox"
:aria-expanded="isPopoverOpen"
aria-controls="event-categories"
class="relative w-full max-w-full h-fit justify-between hover:bg-secondary hover:text-primary-foreground"
>
<template v-if="!model.length">
{{ props.placeholder }}
</template>
<template v-else>
<ul class="flex flex-wrap gap-1">
<li v-for="category in model" :key="`selected-cat-${category.id}`">
<UiBadge class="lowercase" variant="secondary">
{{ category.name }}
</UiBadge>
</li>
</ul>
</template>
<PhCaretDown class="ml-2 size-4 shrink-0 opacity-50" />
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent
id="event-categories"
:align="'start'"
side="bottom"
:collision-padding="50"
class="w-fit h-[33vh] p-0"
>
<UiCommand v-model="modelBuffer" v-model:search-term="searchTerm" :multiple="true">
<UiCommandInput :placeholder="$t('entity.category.search')" />
<UiCommandEmpty>{{ $t('entity.category.notFoundAny') }}</UiCommandEmpty>
<UiCommandList>
<UiCommandGroup>
<UiCommandItem
v-for="category in filteredCategories"
:key="category.id"
:value="category"
class="cursor-pointer flex justify-between items-center"
>
<span>
{{ category.name }}
</span>
<PhCheck v-if="model.find(cat => cat.id === category.id)" />
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { PhCaretDown } from "@phosphor-icons/vue";
import { cn } from "@/lib/utils";
import type { Category } from "@@/models/Category";
const isPopoverOpen = ref<boolean>(false)
const props = defineProps<{
placeholder?: string
}>()
const model = defineModel<Category>()
const { categories: availableCategories } = useCalendar()
const searchTerm = ref<string>("")
function handleCatSelect() {
isPopoverOpen.value = false
}
const filteredCategories = computed(() =>
searchTerm.value === ""
? availableCategories
: availableCategories.filter((category) => {
return category.name.toLowerCase().includes(searchTerm.value.toLowerCase())
})
)
</script>
<template>
<UiPopover v-model:open="isPopoverOpen">
<UiPopoverTrigger as-child>
<UiButton
variant="outline"
role="combobox"
:aria-expanded="isPopoverOpen"
aria-controls="event-category"
class="w-full max-w-full justify-between hover:bg-secondary hover:text-primary-foreground"
>
<template v-if="!model">
{{ props.placeholder }}
</template>
<template v-else>
<span
class="bgc"
:class="cn(`element-${model.color}`)"
>
{{ capitalize(model.name) }}
</span>
</template>
<PhCaretDown class="ml-2 size-4 shrink-0 opacity-50" />
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent
id="event-category"
align="start"
side="bottom"
:collision-padding="50"
class="w-fit h-[33vh] p-0"
>
<UiCommand v-model="model" v-model:search-term="searchTerm">
<UiCommandInput :placeholder="$t('entity.category.search')" />
<UiCommandEmpty>{{ $t('entity.category.notFoundAny') }}</UiCommandEmpty>
<UiCommandList>
<UiCommandGroup>
<UiCommandItem
v-for="category in filteredCategories"
:key="category.id"
:value="category"
class="cursor-pointer"
@select="handleCatSelect"
>
<span
class="bgc"
:class="cn(`element-${category.color}`)"
>
{{ capitalize(category.name) }}
</span>
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,172 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import { useSortable } from "@vueuse/integrations/useSortable";
import type { CalendarMonth } from "@@/models/CalendarMonth";
import { PhList, PhPlus, PhTrash } from "@phosphor-icons/vue";
const model = defineModel<CalendarMonth[]>({ required: true })
/**
* Input value for new month's name
*/
const monthName: Ref<string | undefined> = ref<string>()
const monthNameRef = ref<HTMLInputElement>()
const { focused: monthNameFocused } = useFocus(monthNameRef)
const validMonthNameDatatypes = ["string"]
const monthNameIsTaken = computed(() => model.value.find(m => m.name === monthName.value))
const validMonthName = computed(() => validMonthNameDatatypes.includes(typeof monthName.value) && !monthNameIsTaken.value)
/**
* Input value for new month's number of days
*/
const monthDays: Ref<number | undefined> = ref<number>()
const monthDaysRef = ref<HTMLInputElement>()
const validMonthDaysDatatypes = ["number"]
const validMonthDays = computed(() => validMonthDaysDatatypes.includes(typeof monthDays.value) && monthDays.value && monthDays.value >= 12)
const validNewMonth = computed(() => validMonthDays.value && validMonthName.value)
/**
* Add current month input data to the model list
*/
function addMonthToModel(): void {
if (!monthDays.value || !monthName.value) return
// Create month object to add
const monthToInsert: CalendarMonth = {
name: monthName.value,
days: monthDays.value,
position: model.value.length
}
model.value.push(monthToInsert)
// Reset form state
monthName.value = ""
monthNameFocused.value = true
}
/**
* Remove a specific month from the model
*
* @param index Index position of the month in the model
*/
function removeMonthFromModel(index: number) {
if (isNaN(index)) return
model.value.splice(index, 1)
}
// Sortable setup
const monthSortableList = ref<HTMLElement | null>(null)
useSortable(monthSortableList, model.value, { animation: 150, handle: ".handle" })
</script>
<template>
<div class="grid md:grid-cols-12 gap-4 items-center">
<div class="md:col-start-2 md:col-span-5">
<UiInput
id="new-month-name"
ref="monthNameRef"
v-model="monthName"
type="text"
name="newMonthName"
:placeholder="$t('entity.calendar.months.inputName')"
:class="cn({ 'border-red-600': monthNameIsTaken })"
/>
</div>
<div class="md:col-span-5">
<UiInput
id="new-month-days"
ref="monthDaysRef"
v-model="monthDays"
type="number"
name="newMonthName"
:placeholder="$t('entity.calendar.months.daysNb')"
min="0"
step="1"
class="invalid:border-red-600"
/>
</div>
<div class="md:col-span-1">
<UiButton size="icon" class="rounded-full size-8" :disabled="!validNewMonth" @click.prevent="addMonthToModel">
<PhPlus size="17"/>
</UiButton>
</div>
<div class="md:col-span-full">
<div
class="border-[1px] border-border p-4 rounded-sm max-h-80 overflow-y-auto"
:class="model.length ? 'md:grid md:grid-cols-12 md:gap-4 md:items-center' : ''"
>
<div v-if="model.length" class="hidden md:block col-span-1">
<ul class="grid gap-y-4 justify-center">
<li v-for="(m, i) in model" :key="`num-${m.name}`">
<UiButton size="icon" variant="secondary" class="size-8 rounded-full">
<span class="font-bold text-sm">{{ i + 1 }}</span>
</UiButton>
</li>
</ul>
</div>
<div class="md:col-span-11">
<ul ref="monthSortableList" class="grid gap-y-2" :class="model.length ? 'visible' : 'absolute invisible'">
<template v-if="model.length">
<li v-for="(m, i) in model" :key="m.name" class="grid md:grid-cols-12 gap-4 md:items-center text-slate-900 bg-slate-200 rounded-md">
<div class="md:col-span-1 text-right duration-200 ease-out transition transform origin-top-right">
<UiButton type="button" variant="ghost" size="icon" class="handle rounded-full size-8">
<PhList size="17" />
</UiButton>
</div>
<div class="md:col-span-3">
<div class="font-bold md:pl-2">
{{ m.name }}
</div>
</div>
<div class="md:col-span-2">
<UiInput
:id="`month-days-n${i}`"
v-model="m.days"
class="bg-transparent border-none"
type="number"
:name="`monthDays-n${i}`"
:placeholder="$t('entity.calendar.months.daysNb')"
min="0"
/>
</div>
<div class="md:col-span-1">
{{ $t('entity.calendar.months.daysMaybePlural') }}
</div>
<div class="md:col-start-12">
<UiTooltipProvider>
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton type="button" variant="ghost" size="icon" class="rounded-full size-8" @click="removeMonthFromModel(i)">
<PhTrash size="17" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.months.deleteOne', { month: m.name }) }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</li>
</template>
</ul>
</div>
<template v-if="!model.length">
<p class="col-span-12 text-lg text-center italic opacity-50">
{{ $t('entity.calendar.months.none') }}
</p>
</template>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date";
import {
PhXCircle
} from "@phosphor-icons/vue"
import { cn } from "~/lib/utils";
const model = defineModel<RPGDate | null>()
const id = useId()
const props = defineProps<{
initialDate?: RPGDate
placeholder?: string
required?: boolean
}>()
const { getFormattedDateTitle, getMonthName, defaultDate } = useCalendar()
const { sortedMonths } = storeToRefs(useCalendar())
const popoverOpened = ref<boolean>(false)
const inputPlaceholder = computed<string>(() => {
if (model.value) {
return getFormattedDateTitle(model.value, true)
} else if (props.placeholder) {
return props.placeholder
}
return ""
})
const initialDateValue = props.initialDate ? { ...props.initialDate } : null
const monthTitle = computed<string>(() => {
if (model.value) {
return getMonthName(model.value.month)
}
return ""
})
const hasValue = computed<boolean>(() => !!model.value)
const monthData = computed(() => {
if (model.value) {
return sortedMonths.value[model.value.month]
}
return null
})
function setModelDay(day: number) {
if (model.value) {
model.value.day = day
}
}
function handleMonthChange(e: string) {
const monthId = Number(e)
if (model.value) {
model.value.month = monthId
}
}
/**
* Handle click if the date is null
*/
function handleNullClick() {
// Early return
if (model.value) return
if (!model.value) {
if (props.initialDate) {
model.value = initialDateValue
} else {
model.value = { ...defaultDate }
}
}
}
function handleValueReset() {
// Early return
if (!model.value) return
model.value = null
}
</script>
<template>
<UiPopover v-model:open="popoverOpened">
<UiPopoverTrigger>
<div class="flex">
<UiButton
size='sm'
variant="outline"
:class="{
'text-slate-500': !hasValue,
'rounded-tr-none rounded-br-none': model
}"
@click.prevent="handleNullClick"
>
{{ inputPlaceholder }}
</UiButton>
<UiButton
v-if="model && !required"
size='sm'
variant="outline"
class="rounded-tl-none rounded-bl-none px-2"
@click.prevent="handleValueReset"
>
<PhXCircle size="14" />
</UiButton>
</div>
</UiPopoverTrigger>
<UiPopoverContent
v-if="model"
align="start"
side="bottom"
>
<div class="grid grid-cols-2 items-center gap-x-2 gap-y-3">
<UiSelect @update:model-value="handleMonthChange">
<UiSelectTrigger>
<UiSelectValue :placeholder="monthTitle" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectGroup class="max-h-[50vh]">
<UiSelectItem v-for="(month, i) in sortedMonths" :key="`popover-select-item-${id}-${i}`" :value="i.toString()">
{{ month.name }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
<UiInput v-model="model.year" type="number" />
<hr class="col-span-2" />
<div class="col-span-2">
<div v-if="monthData" class="grid grid-cols-7 gap-1">
<button
v-for="day in monthData.days"
:key="`popover-day-grid-${id}-${day}`"
class="aspect-square rounded-full text-[.8em] transition-colors"
:class="cn({
'hover:bg-accent hover:text-accent-foreground': day !== model.day,
'bg-primary text-primary-foreground hover:bg-accent hover:text-accent-foreground': day === model.day
})"
@click="setModelDay(day)"
>
{{ day }}
</button>
</div>
</div>
</div>
</UiPopoverContent>
</UiPopover>
</template>

View File

@@ -0,0 +1,71 @@
<script lang="ts" setup>
import type { CalendarMonth } from "@@/models/CalendarMonth";
import type { RPGDate } from "@@/models/Date";
import { PhCalendarBlank } from "@phosphor-icons/vue";
const model = defineModel<RPGDate>({ required: true })
const props = defineProps<{
availableMonths: CalendarMonth[]
}>()
/**
*
* @param e The name of today's month
*/
function setTodayMonth(e: string) {
model.value.month = e
}
// When the model changes, get the month index from the month name
watch(model.value, (n, _o) => {
// If the month value is already an index, return early
if (!isNaN(+model.value.month)) return
const monthId = props.availableMonths.findIndex((m) => m.name === n.month)
if (monthId !== -1) {
model.value.month = monthId + 1
}
})
</script>
<template>
<div class="flex gap-2 items-stretch">
<UiInput id="new-month-current-day" v-model="model.day" type="number" name="newMonthCurrentDay" :placeholder="$t('entity.calendar.months.daysNameSingular')" min="1" step="1" class="invalid:border-red-600" />
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child :disabled="props.availableMonths.length < 1">
<UiButton size="sm" variant="secondary">
<PhCalendarBlank size="18" weight="fill" />
<template v-if="props.availableMonths.length < 1">
{{ $t('entity.calendar.months.noneAvailable') }}
</template>
<template v-else-if="model.month && typeof model.month === 'number'">
{{ props.availableMonths[model.month - 1].name }}
</template>
<template v-else>
{{ $t('entity.calendar.months.chooseOne') }}
</template>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent :side="'bottom'" :collision-padding="30">
<UiDropdownMenuLabel>
{{ $t('entity.calendar.months.available') }}
</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem
v-for="m in props.availableMonths"
:key="m.name"
@click="setTodayMonth(m.name)"
>
{{ m.name }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<UiInput id="new-month-current-day" v-model="model.year" type="number" name="newMonthCurrentYear" :placeholder="$t('entity.calendar.years.nameSingular')" step="1" class="invalid:border-red-600" />
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { breakpointsTailwind } from "@vueuse/core"
const breakpoints = useBreakpoints(
breakpointsTailwind
)
const { isReadOnly, defaultDate } = storeToRefs(useCalendar())
</script>
<template>
<header class="md:mt-2 grid gap-3 md:gap-4 border-border border-b-[1px] transition-colors">
<div class="px-5 md:px-8 flex items-center justify-between gap-2">
<menu class="flex items-center md:gap-2">
<li>
<LazyCalendarDialogQuickCreateEvent v-if="!isReadOnly" />
</li>
<li class="max-md:hidden">
<LazyCalendarMenuToday v-if="defaultDate" />
</li>
<li class="ml-2 md:ml-4">
<CalendarCurrentDate />
</li>
</menu>
<menu class="flex items-center gap-2">
<li>
<CalendarSearchCTA />
</li>
<ClientOnly>
<li v-if="breakpoints.md.value">
<CalendarCategoriesCTA />
</li>
</ClientOnly>
<li>
<CalendarOptionsCTA />
</li>
</menu>
</div>
<div class="ml-4 md:ml-8">
<CalendarMenuSubnav />
</div>
</header>
</template>

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-center w-40 px-3 md:px-4 py-2 bg-background border-border border-x-[1px] border-t-[1px] rounded-t-sm text-sm max-md:text-xs 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

@@ -0,0 +1,41 @@
<script lang="ts" setup>
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { PhCalendar } from "@phosphor-icons/vue"
const { defaultDate, jumpToDefaultDate, getFormattedDateTitle, currentDate } = useCalendar()
const defaultDateFormatted: string = getFormattedDateTitle(defaultDate, true)
const buttonDisabledState: ComputedRef<boolean> = computed<boolean>(() => {
return currentDate.currentMonth === defaultDate.month && currentDate.currentYear === defaultDate.year
})
const breakpoints = useBreakpoints(
breakpointsTailwind
)
</script>
<template>
<ClientOnly>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
:size="breakpoints.md.value ? 'default' : 'icon'"
variant="secondary"
:disabled="buttonDisabledState" @click="jumpToDefaultDate"
>
<PhCalendar v-if="!breakpoints.md.value" size="20" weight="fill" />
<span v-if="breakpoints.md.value">
{{ $t('entity.calendar.date.today') }}
</span>
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ defaultDateFormatted }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</ClientOnly>
</template>

View File

@@ -0,0 +1,460 @@
<script lang="ts" setup>
import {
isCharacter,
type Character,
} from "@@/models/Characters"
import type { RPGDateOrder } from "@@/models/Date"
import {
isCalendarEvent,
type CalendarEvent,
} from "@@/models/CalendarEvent"
import { capitalize } from "~/utils/Strings"
import { useMagicKeys, useScroll, useStorage, whenever } from "@vueuse/core"
import { computed, ref, watch } from "vue"
import { searchUnifier, type SearchMode } from "../SearchMode"
import { PhCaretDown, PhClockClockwise, PhClockCounterClockwise, PhMagnifyingGlass } from "@phosphor-icons/vue"
import {
ComboboxAnchor,
ComboboxInput,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
VisuallyHidden
} from "radix-vue"
import SearchList from "./lists/SearchList.vue"
import type { Category } from "@@/models/Category"
import { cn } from "@/lib/utils"
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)
// If the query changes, resets the pagination
// This prevents active page to be greater than the search results
watch(searchQuery, resetPage)
const selectedEntity = useStorage("se", "events" as SearchMode)
// Order
const selectedOrder = ref<RPGDateOrder>("asc")
function setOrderAsc() {
selectedOrder.value = "asc"
resetPage()
}
function setOrderDesc() {
selectedOrder.value = "desc"
resetPage()
}
// Limit
const currentPage = ref<number>(1)
const itemsPerPage: number = 20
const startOfList = computed<number>(() => (currentPage.value - 1) * itemsPerPage)
const endOfList = computed<number>(() => startOfList.value + itemsPerPage)
/**
* Resets the pagination
*/
function resetPage() {
currentPage.value = 1
}
const searchResults = computed<(Character | CalendarEvent)[]>(() => {
let results: (Character | CalendarEvent)[] = []
let dataToFilter: Character[] | CalendarEvent[] | (Character | CalendarEvent)[]
const shouldFilterCategories = selectedCategories.value.length > 0
// Assign data to loop over and filter
// They are assigned this way for readability
if (selectedEntity.value === "events") {
dataToFilter = allEvents.value
} else if (selectedEntity.value === "characters") {
dataToFilter = characters.value
} else {
dataToFilter = [...allEvents.value, ...characters.value]
}
/**
* TODO: Refactor the categories logic, basically extract the return out of the ifs, like above
*/
results = dataToFilter.filter((item) => {
// Filter calendar events
if (isCalendarEvent(item)) {
const queryString = (searchQuery.value as string)
.replace(searchUnifier, "")
.toLocaleLowerCase()
const hitTitle = item.title
.replace(searchUnifier, "")
.toLocaleLowerCase()
.includes(queryString)
const hitDesc = item.description
?.replace(searchUnifier, "")
.toLocaleLowerCase()
.includes(queryString)
if (!shouldFilterCategories) {
return hitTitle || hitDesc
}
// Handle categories logic
let hitCategories: boolean = false
const allCategories: Category[] = []
if (item.category) {
allCategories.push(item.category)
}
if (item.secondaryCategories && item.secondaryCategories?.length > 0) {
allCategories.push(...item.secondaryCategories)
}
hitCategories = selectedCategories.value.every((selectedCat) => {
return allCategories.findIndex((c) => c.name === selectedCat.name) !== -1
})
return (hitTitle || hitDesc) && hitCategories
}
// Filter characters
if (isCharacter(item)) {
const queryString = (searchQuery.value as string)
.replace(searchUnifier, "")
.toLocaleLowerCase()
const hitTitle = item.name
.replace(searchUnifier, "")
.toLocaleLowerCase()
.includes(queryString)
if (!shouldFilterCategories) {
return hitTitle
}
// Handle categories logic
let hitCategories: boolean = false
const allCategories: Category[] = []
if (item.category) {
allCategories.push(item.category)
}
if (item.secondaryCategories && item.secondaryCategories?.length > 0) {
allCategories.push(...item.secondaryCategories)
}
hitCategories = selectedCategories.value.every((selectedCat) => {
return allCategories.includes(selectedCat as Category)
})
return hitTitle && hitCategories
}
})
return results
})
/**
* Removes the search query, resets the pagination and removes all selected categories
*/
function resetSearch() {
searchQuery.value = ""
resetPage()
selectedCategories.value = []
}
/**
* Opens the search Uidialog
*/
function openUiDialog() {
isAdvancedSearchOpen.value = true
}
/**
* Closes the search Uidialog
*/
function closeUiDialog() {
isAdvancedSearchOpen.value = false
}
/**
* Switches the selectedEntity
*/
function handleEntitySwitch() {
resetPage()
selectedCategories.value = []
}
// Key combos to deploy modal
const keys = useMagicKeys()
const controlPeriod = computed(() => keys.control_period?.value)
whenever(controlPeriod, openUiDialog)
const searchResultsRef = ref<HTMLElement | null>(null)
const { y: searchResultsY } = useScroll(searchResultsRef)
watch([currentPage, selectedEntity], () => {
searchResultsY.value = 0
})
// Compute categories based on current selectedEntity
const currentCategories = computed(() => categories.value)
const selectedCategories = ref<(Category)[]>([])
const categoryFilterOpened = ref<boolean>(false)
const searchCategory = ref<string>("")
const filteredCategories = computed(() => {
// Display original data
if (!currentCategories.value) return []
// If current categories are selected, ignore them
if (!searchCategory.value) return currentCategories.value.filter((i) => !selectedCategories.value.includes(i))
// If we also have a query to filter them by, do that
return currentCategories.value.filter((i) => {
return !selectedCategories.value.includes(i) && i.name.toLocaleLowerCase().includes(searchCategory.value.toLocaleLowerCase())
})
})
// Reactivity rules for category searches
const categoryInput = ref(null)
const categoryInputValue = ref<string>("")
const { focused: categoryInputFocused } = useFocus(categoryInput)
watch(categoryInputFocused, (isFocused) => {
categoryFilterOpened.value = isFocused
})
/**
* Handles the category selections from the TagInput component
*
* @param e Radix Change Event
*/
function handleCategorySelect(e: Category) {
searchCategory.value = ""
selectedCategories.value.push(e)
if (filteredCategories.value.length === 0) {
categoryFilterOpened.value = false
}
}
/**
* Removes a category from selection from the TagInput component
*/
function handleCategoryUnselect(e: Category) {
selectedCategories.value.splice(selectedCategories.value.findIndex(sc => sc.name === e.name), 1)
if (filteredCategories.value.length === 0) {
categoryFilterOpened.value = false
}
}
</script>
<template>
<UiDialog v-model:open="isAdvancedSearchOpen" @update:open="resetSearch()">
<UiDialogContent
class="flex flex-col flex-nowrap top-10 -translate-y-0 data-[state=closed]:slide-out-to-top-[5%] data-[state=open]:slide-in-from-top-[5%] max-md:rounded-none"
:class="cn({
'max-md:w-full bottom-0 md:bottom-10 top-0 md:top-10': searchResults.length > 0
})"
>
<VisuallyHidden>
<UiDialogTitle> {{ $t('entity.advancedSearch.title') }} </UiDialogTitle>
</VisuallyHidden>
<VisuallyHidden>
<UiDialogDescription>
{{ $t('entity.advancedSearch.title') }}
</UiDialogDescription>
</VisuallyHidden>
<div id="searchForm" class="grid gap-3">
<div class="relative w-full h-fit">
<UiInput
id="search"
ref="searchInput"
v-model:model-value="searchQuery"
type="text"
:placeholder="$t('entity.advancedSearch.ctaPlaceholder')"
class="pl-10 py-6 text-lg"
autocomplete="off"
/>
<span class="absolute start-1 inset-y-0 flex items-center justify-center px-2 opacity-50">
<PhMagnifyingGlass size="20" />
</span>
</div>
<div class="flex items-center justify-between gap-8">
<div class="hidden">
<UiToggleGroup
v-model="selectedEntity"
type="single"
class="justify-start"
@update:model-value="handleEntitySwitch()"
>
<UiToggleGroupItem value="events" aria-label="Uniquement les évènements">
{{ $t('entity.calendar.event.namePlural') }}
</UiToggleGroupItem>
</UiToggleGroup>
</div>
<div class="grow flex justify-end items-center gap-1">
<UiTagsInput class="grow px-0 gap-y-1 md:w-80">
<div v-if="selectedCategories.length > 0" class="flex gap-2 flex-wrap items-center px-3">
<UiTagsInputItem v-for="cat in selectedCategories" :key="cat.id" :value="cat.name">
<button
class="bgc px-2 capitalize cursor-pointer"
:class="cn(`element-${cat.color}`)"
@click="handleCategoryUnselect(cat)"
>
{{ capitalize(cat.name) }}
</button>
</UiTagsInputItem>
</div>
<ComboboxRoot
v-model="selectedCategories"
v-model:open="categoryFilterOpened"
v-model:search-term="searchCategory"
class="grow flex items-center gap-y-1 pr-2"
>
<ComboboxAnchor as-child>
<ComboboxInput :placeholder="$t('entity.category.namePlural')" as-child>
<UiTagsInputInput
ref="categoryInput"
v-model="categoryInputValue"
class="min-w-16 px-3"
@keydown.enter.prevent
/>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxTrigger>
<PhCaretDown size="16" />
</ComboboxTrigger>
<ComboboxPortal :to="'#searchForm'">
<UiCommandList
position="popper"
class="w-(--radix-popper-anchor-width) rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50"
:dismissable="true"
>
<UiCommandEmpty />
<UiCommandGroup>
<UiCommandItem
v-for="cat in filteredCategories"
:key="cat.name"
:value="cat"
@select.prevent="handleCategorySelect(cat)"
>
<span
class="bgc"
:class="cn(`element-${cat.color}`)"
>
{{ capitalize(cat.name) }}
</span>
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</ComboboxPortal>
</ComboboxRoot>
</UiTagsInput>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
:variant="selectedOrder === 'desc' ? 'secondary' : 'outline'"
size="icon"
@click="setOrderDesc()"
>
<PhClockCounterClockwise size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ $t('entity.advancedSearch.older') }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
:variant="selectedOrder === 'asc' ? 'secondary' : 'outline'"
size="icon"
@click="setOrderAsc()"
>
<PhClockClockwise size="18" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>{{ $t('entity.advancedSearch.newer') }}</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
</div>
<hr>
<div v-if="searchResults.length > 0" ref="searchResultsRef" class="grow overflow-y-auto">
<SearchList
:results="searchResults"
:current-entity="selectedEntity"
:order="selectedOrder"
:start-at="startOfList"
:end-at="endOfList"
@jumped-to-date="closeUiDialog()"
/>
</div>
<div class="flex justify-end">
<UiPagination
v-model:page="currentPage"
:total="searchResults.length"
:items-per-page="itemsPerPage"
:sibling-count="2"
show-edges
:default-page="1"
>
<UiPaginationList v-slot="{ items }" class="flex items-center gap-1">
<UiPaginationFirst />
<UiPaginationPrev />
<template v-for="(item, index) in items">
<UiPaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<UiButton
class="size-8 md:size-10 p-0"
:variant="item.value === currentPage ? 'default' : 'outline'"
>
{{ item.value }}
</UiButton>
</UiPaginationListItem>
<UiPaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<UiPaginationNext />
<UiPaginationLast />
</UiPaginationList>
</UiPagination>
</div>
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -1,25 +1,27 @@
<script lang="ts" setup>
import type { Character } from '@/models/Characters'
import type { LeimDate } from '@/models/Date'
import { useCalendar } from '@/stores/CalendarStore'
import type { Character } from "@@/models/Characters"
import type { RPGDate } from "@@/models/Date"
import { useCalendar } from "@/stores/CalendarStore"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { PhArrowSquareOut, PhPlant, PhSkull } from '@phosphor-icons/vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/components/ui/tooltip"
import { Button } from "~/components/ui/button"
import { PhArrowSquareOut, PhPlant, PhSkull } from "@phosphor-icons/vue"
defineProps<{
const props = defineProps<{
character: Character
}>()
defineEmits<{
(e: 'query:date-jump', payload: LeimDate): void
"query:date-jump": [payload: RPGDate]
}>()
const { getFormattedDateTitle } = useCalendar()
console.log(props.character.birth)
</script>
<template>
<div class="block w-full text-left py-3 px-4 border-[1px] border-slate-700 rounded-sm">
<div class="block w-full text-left py-3 px-4 border-[1px] border-border rounded-sm">
<div class="grid gap-2">
<div class="flex gap-2">
<h2 class="font-bold">
@@ -35,15 +37,15 @@ const { getFormattedDateTitle } = useCalendar()
</div>
</div>
<menu class="flex gap-2 border-[1px] border-slate-700 rounded-sm w-fit">
<menu class="flex gap-2 border-[1px] border-border rounded-sm w-fit">
<li v-if="character.birth">
<TooltipProvider :delayDuration="100">
<TooltipProvider :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<Button
@click="$emit('query:date-jump', character.birth!)"
variant="ghost"
size="xs"
@click="$emit('query:date-jump', character.birth!)"
>
<PhPlant size="16" weight="fill" />
{{ getFormattedDateTitle(character.birth, true) }}
@@ -57,13 +59,13 @@ const { getFormattedDateTitle } = useCalendar()
</li>
<span v-if="character.birth && character.death">-</span>
<li v-if="character.death">
<TooltipProvider :delayDuration="100">
<TooltipProvider :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<Button
@click="$emit('query:date-jump', character.death!)"
variant="ghost"
size="xs"
@click="$emit('query:date-jump', character.death!)"
>
<PhSkull size="16" weight="fill" />
{{ getFormattedDateTitle(character.death, true) }}
@@ -77,7 +79,7 @@ const { getFormattedDateTitle } = useCalendar()
</li>
</menu>
<hr v-if="character.description" class="border-white opacity-25" />
<hr v-if="character.description" class="border-white opacity-25" >
<div v-if="character.description" class="text-sm">
<span class="opacity-75">

View File

@@ -0,0 +1,119 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils"
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { PhArrowSquareOut, PhHourglassMedium, PhAlarm, PhMapPinArea, PhEye } from "@phosphor-icons/vue"
const props = defineProps<{
event: CalendarEvent
}>()
defineEmits<{
"query:date-jump": [payload: RPGDate]
}>()
const { getRelativeString, defaultDate, getFormattedDateTitle } = useCalendar()
const dateDifference: string = getRelativeString(defaultDate, props.event.startDate)
const dateDuration = computed<string | null>(() => props.event.endDate ? getRelativeString(props.event.startDate, props.event.endDate, "compact") : null)
</script>
<template>
<button
class="event-callout group relative block w-full text-left py-2 px-3 md:py-3 md:px-4 cursor-pointer transition-colors"
:class="cn(
event.category ? `element-${event.category.color}` : '',
{
'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-semibold md:font-bold md:text-lg underline-offset-4 group-hover:underline">
{{ event.title }}
</h2>
<div v-if="event.wiki">
<UiButton variant="link" size="xs" as-child class="text-inherit">
<a :href="event.wiki" target="_blank">
Wiki
<PhArrowSquareOut size="16" weight="fill" />
</a>
</UiButton>
</div>
</div>
<div class="flex gap-2 items-center justify-between mb-2 md:mb-1">
<template v-if="!event.endDate">
<p class="font-semibold text-sm opacity-75">
{{ getFormattedDateTitle(event.startDate, true) }}
</p>
</template>
<template v-else>
<p class="font-semibold text-sm opacity-75">
{{
$t('entity.calendar.date.fromTo',
{
startDate: getFormattedDateTitle(event.startDate, true),
endDate: getFormattedDateTitle(event.endDate, true)
}
)
}}
</p>
</template>
<div v-if="event.hidden" class="flex justify-end">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiBadge class="flex gap-1">
<PhEye size="16" weight="fill" /> {{ $t('entity.calendar.event.isHidden') }}
</UiBadge>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('entity.calendar.event.hiddenTooltip') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
<div class="mb-1 flex gap-x-2 items-center">
<template v-if="event.location">
<p class="w-fit text-xs md:text-sm italic opacity-75 flex items-center gap-1">
<PhMapPinArea size="16" weight="fill" /> {{ event.location }}
</p>
</template>
<p class="w-fit text-xs md:text-sm italic opacity-75 flex items-center gap-1">
<PhAlarm size="16" weight="fill" /> {{ dateDifference }}
</p>
<template v-if="dateDuration">
<p class="w-fit text-xs md:text-sm italic opacity-75 flex items-center gap-1">
<PhHourglassMedium size="16" weight="fill" /> {{ $t('entity.calendar.date.while', { duration: dateDuration } )}}
</p>
</template>
</div>
<div v-if="event.category || event.secondaryCategories" class="absolute top-3 right-4">
<ul class="flex gap-1">
<li v-if="event.category">
<UiBadge class="mix-blend-luminosity font-bold bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ event.category?.name }}
</UiBadge>
</li>
<li v-for="cat in event.secondaryCategories" :key="cat.id">
<UiBadge class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600 lowercase" variant="secondary">
{{ cat.name }}
</UiBadge>
</li>
</ul>
</div>
<div v-if="event.description" class="text-xs md:text-sm">
<hr class="my-3 md:my-2 border-white opacity-50" >
<div class="content-editor opacity-75" v-html="event.description" />
</div>
</button>
</template>

View File

@@ -1,40 +1,39 @@
<script lang="ts" setup>
import { isCharacter, type Character } from '@/models/Characters'
import { compareDates, type LeimDate, type LeimDateOrder } from '@/models/Date'
import { isCalendarEvent, type CalendarEvent } from '@/models/Events'
import { useCalendar } from '@/stores/CalendarStore'
import { computed, ref } from 'vue'
import type { SearchMode } from '../../Search'
import { isCharacter, type Character } from "@@/models/Characters"
import type { RPGDate, RPGDateOrder } from "@@/models/Date"
import { useCalendar } from "@/stores/CalendarStore"
import { computed } from "vue"
import { isCalendarEvent, type CalendarEvent } from "@@/models/CalendarEvent"
import type { SearchMode } from "../../SearchMode"
import CharacterCallout from './CharacterCallout.vue'
import EventCallout from './EventCallout.vue'
import { useScroll } from '@vueuse/core'
import CharacterCallout from "./CharacterCallout.vue"
import EventCallout from "./EventCallout.vue"
const props = defineProps<{
results: (Character | CalendarEvent)[]
currentEntity: SearchMode
order: LeimDateOrder
order: RPGDateOrder
startAt: number
endAt: number
limit?: number
}>()
const emit = defineEmits(['jumpedToDate'])
const emit = defineEmits(["jumpedToDate"])
const { jumpToDate } = useCalendar()
const { jumpToDate, compareDates } = useCalendar()
function handleJumpToDate(date?: LeimDate) {
function handleJumpToDate(date?: RPGDate) {
if (!date) return
jumpToDate(date)
emit('jumpedToDate')
emit("jumpedToDate")
}
// Initial sorting of the results
const sortedResults = computed(() => {
return [...props.results].sort((a, b) => {
let firstDate: LeimDate
let secondDate: LeimDate
let firstDate: RPGDate
let secondDate: RPGDate
if (isCalendarEvent(a)) {
firstDate = a.startDate
@@ -61,7 +60,7 @@ const pagedResults = computed(() => sortedResults.value.slice(props.startAt, pro
</script>
<template>
<ul class="grid gap-4">
<ul class="grid gap-3 md:gap-4 pl-4 pr-6 md:pl-8 md: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

@@ -0,0 +1,264 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date"
import type { CalendarEvent } from "@@/models/CalendarEvent"
import { breakpointsTailwind, useElementBounding } from "@vueuse/core"
import { storeToRefs } from "pinia"
import { computed, ref, type ComputedRef } from "vue"
import CalendarEventButton from "../../event/Event.vue"
import { useToast } from "@/components/ui/toast"
import type { APIError } from "@@/models/Errors"
import { ToastLifetime } from "@/components/ui/toast/use-toast"
const props = defineProps<{
date: RPGDate
faded?: boolean
}>()
const emit = defineEmits<{
"on-open-create-dialog": [date: RPGDate]
}>()
const { toast } = useToast()
const { t } = useI18n()
const calendarTile = ref()
const calendarEventsList = ref()
const { defaultDate, selectDate, areDatesIdentical, getFormattedDateTitle, updateEventFromSkeleton, resetSkeleton } = useCalendar()
const { selectedDate, currentEvents, isReadOnly, eventSkeleton, isDraggingEvent } = storeToRefs(useCalendar())
const breakpoints = useBreakpoints(
breakpointsTailwind
)
/**
* All events with a startDate / endDate that starts or ends on the tile
*/
const eventsForTheDay = computed(() => {
return currentEvents.value.filter((currentEvent) => {
return (
areDatesIdentical(currentEvent.startDate, props.date) ||
areDatesIdentical(currentEvent.endDate!, props.date)
)
})
})
/**
* Is the tile on today's date ?
*/
const isDefaultDate = computed(() => {
return areDatesIdentical(props.date, defaultDate)
})
/**
* Is the tile on the hightlighted date ?
*/
const isSelectedDate = computed(() => {
return areDatesIdentical(props.date, selectedDate.value)
})
// Get bounding elements for both tile and events list
const { height: tileHeight, top: tileTop } = useElementBounding(calendarTile)
const { top: tileListTop } = useElementBounding(calendarEventsList)
// Compute the available number of events that can be displayed from refs heights
const numberOfEventsToFit: ComputedRef<number> = computed(() => {
if (!eventsForTheDay.value.length) return 0
let offset = 0
if (isDraggingEvent.value && !tileNotHovered.value && !skeletonInsideCurrentDay.value) {
offset = 1
}
return Math.trunc((tileHeight.value - (tileListTop.value - tileTop.value)) / 40) - offset
})
const skeletonInsideCurrentDay = computed(() => eventsForTheDay.value.find((e) => e.id === eventSkeleton.value.id))
/**
* Events that can fit in the tile's space
*/
const eventsToDisplay: ComputedRef<CalendarEvent[]> = computed<CalendarEvent[]>(() => {
return [...eventsForTheDay.value].splice(0, numberOfEventsToFit.value)
})
/**
* Remaining events that are not displayed
*
* Used if not all events can be seen in the tile's space
*/
const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsForTheDay.value.length - eventsToDisplay.value.length)
// Handle drop events
const { isOutside: tileNotHovered } = useMouseInElement(calendarTile)
const isLoading = ref(false)
async function handleTileDrop() {
if (isLoading.value) return
if (skeletonInsideCurrentDay.value) return
isLoading.value = true
eventSkeleton.value.startDate = props.date
const { error } = await tryCatch(updateEventFromSkeleton())
if (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiError = (error as any).data as APIError
apiError.data.errors.forEach((error) => {
toast({
title: t("entity.calendar.event.editErrors.toastTitle"),
variant: "destructive",
description: t(`entity.calendar.event.editErrors.${error.path[1]}_${error.code}`),
duration: ToastLifetime.MEDIUM,
})
})
isLoading.value = false
return
}
isLoading.value = false
resetSkeleton()
}
</script>
<template>
<div
ref="calendarTile"
class="tile relative p-1 md:p-2 md:pt-1 transition-all"
:class="{
'text-slate-300 dark:text-slate-500': props.faded,
'text-slate-500 dark:text-slate-300': !props.faded
}"
@drop.prevent="handleTileDrop"
@dragenter.prevent
@dragover.prevent
>
<button
class="relative z-10 group block w-full text-2xs md:text-xs text-center cursor-pointer"
@click="selectDate(date)"
>
<ClientOnly>
<span
class="inline-flex size-7 md:size-8 aspect-square items-center justify-center rounded-full border-1 md:border-2 border-transparent font-bold transition-colors group-hover:border-primary dark:group-hover:border-primary"
:class="{
'bg-secondary dark:bg-slate-700': isDefaultDate && !isSelectedDate,
'bg-primary text-primary-foreground': isSelectedDate
}"
>
{{ date.day }}
</span>
</ClientOnly>
</button>
<ClientOnly>
<ul
ref="calendarEventsList"
class="absolute top-8 md:top-10 bottom-1 md:bottom-2 inset-x-2 grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity"
:class="{
'opacity-40': props.faded && !isSelectedDate
}"
>
<li
v-if="!tileNotHovered && isDraggingEvent && !skeletonInsideCurrentDay"
:key="eventSkeleton.id"
class="opacity-60"
>
<CalendarEventButton :event="eventSkeleton" :tile-date="date" />
</li>
<TransitionGroup :name="!isDraggingEvent ? 'event' : ''">
<li
v-for="(event, i) in eventsToDisplay"
:key="event.id"
class="grid w-full pointer-events-auto"
>
<CalendarEventButton :event :tile-date="date" />
</li>
</TransitionGroup>
<li v-if="eventsNotDisplayed > 0" class="pointer-events-auto">
<UiPopover>
<UiPopoverTrigger as-child>
<button
class="text-2xs md:text-xs px-[5px] py-[5px] md:px-2 md:py-1 block w-full text-left font-bold rounded-sm whitespace-nowrap overflow-hidden text-ellipsis cursor-pointer transition-colors hover:text-foreground hover:bg-foreground/10"
>
<template v-if="eventsNotDisplayed === 1">
{{ $t('entity.calendar.event.oneOtherEvent') }}
</template>
<template v-else>
{{ $t('entity.calendar.event.multipleOtherEvents', { count: eventsNotDisplayed }) }}
</template>
</button>
</UiPopoverTrigger>
<UiPopoverContent
class="w-80"
:align="'center'"
:align-offset="breakpoints.lg.value ? 50 : 25"
:collision-padding="breakpoints.lg.value ? 50 : 25"
:side="breakpoints.lg.value ? 'left' : 'bottom'"
>
<div class="text-center mb-2">
<span
class="text-lg font-semibold rounded-full"
>
{{ getFormattedDateTitle(date, true) }}
</span>
</div>
<ul class="grid auto-rows-min gap-1 z-10 pointer-events-none transition-opacity">
<li
v-for="event in eventsForTheDay"
:key="event.title"
class="grid pointer-events-auto"
>
<CalendarEventButton :event :tile-date="date" />
</li>
</ul>
</UiPopoverContent>
</UiPopover>
</li>
</ul>
</ClientOnly>
<ClientOnly>
<LazyCalendarDialogCreateEvent v-if="!isReadOnly && breakpoints.lg.value" :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
<button
v-else-if="!isReadOnly && !breakpoints.lg.value"
class="absolute inset-0 w-full h-full cursor-default z-0"
@click="emit('on-open-create-dialog', props.date)"
/>
</ClientOnly>
</div>
</template>
<style lang="scss" scoped>
.tile {
border-right-width: 1px;
border-bottom-width: 1px;
}
.event-enter-active {
transition: all 120ms ease-in-out;
}
.event-move,
.event-leave-active {
position: absolute;
transition: all 200ms ease-in-out;
}
.event-enter-from,
.event-leave-to {
opacity: 0;
}
.event-enter-from {
transform: translateX(.15rem);
}
.event-leave-to {
transform: translateX(-.5rem);
}
</style>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { useThrottleFn } from "@vueuse/core"
import type { RPGDate } from "@@/models/Date"
import { VisuallyHidden } from "radix-vue"
const { currentDate, decrementViewMonth, incrementViewMonth, resetSkeleton } = useCalendar()
const { currentMonthData, operationInProgress, eventSkeleton } = storeToRefs(useCalendar())
function handleWheel(e: WheelEvent) {
// Prevent scrolling
e.preventDefault()
const isMovingUp = e.deltaY < 0
if (isMovingUp) {
moveCalendarLeft()
} else {
moveCalendarRight()
}
}
const moveCalendarLeft = useThrottleFn(() => {
decrementViewMonth()
}, 100)
const moveCalendarRight = useThrottleFn(() => {
incrementViewMonth()
}, 100)
const isDialogOpen = ref<boolean>(false);
/**
* Opens event creation's popover
*/
function handleDialogOpen(date: RPGDate) {
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
if (operationInProgress.value) {
isDialogOpen.value = false
return
}
resetSkeleton()
isDialogOpen.value = true
// Set skeleton initial startDate if it's known
if (date) {
eventSkeleton.value.startDate = date
}
}
function toggleDialog() {
isDialogOpen.value = !isDialogOpen.value;
};
/**
* Prevents the modal from closing if's still loading
*/
function handleClosing() {
setTimeout(() => resetSkeleton(), 100)
}
</script>
<template>
<div class="grid grid-cols-5 md:grid-cols-7 lg:grid-cols-10" @wheel="handleWheel">
<template v-if="currentMonthData">
<CalendarStateMonthlyDayTile
v-for="day in currentMonthData?.days"
:key="`layout-month-grid-${day}`"
:date="{
day: day,
month: currentDate.currentMonth,
year: currentDate.currentYear
}"
@on-open-create-dialog="handleDialogOpen"
/>
<UiDialog v-model:open="isDialogOpen">
<UiDialogContent
class="max-md:translate-0 max-md:inset-0 max-md:w-full max-md:block"
:trap-focus="true"
@escape-key-down="handleClosing"
@pointer-down-outside.prevent="null"
>
<UiDialogTitle class="max-md:mb-8">
{{ $t("entity.calendar.event.addSingle") }}
</UiDialogTitle>
<VisuallyHidden>
<UiDialogDescription>
{{ $t("entity.calendar.event.addSingleDescription") }}
</UiDialogDescription>
</VisuallyHidden>
<CalendarFormCreateEvent @event-created="toggleDialog" />
</UiDialogContent>
</UiDialog>
</template>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script lang="ts" setup>
import type { RPGDate } from "@@/models/Date"
import { storeToRefs } from "pinia"
import { computed, type ComputedRef } from "vue"
import { cn } from "~/lib/utils"
const { currentDate, defaultDate, selectDate, areDatesIdentical } = useCalendar()
const { selectedDate, currentEvents } = storeToRefs(useCalendar())
const props = defineProps<{
monthNumber: number
dayNumber: number
}>()
const tileDate: ComputedRef<RPGDate> = computed(() => {
return {
day: props.dayNumber,
month: props.monthNumber,
year: currentDate.currentYear
}
})
const isDefaultDate = computed<boolean>(() => {
return areDatesIdentical(tileDate.value, defaultDate)
})
const isSelectedDate = computed<boolean>(() => {
return areDatesIdentical(tileDate.value, selectedDate.value)
})
const hasAtLeastOneEvent = computed<boolean>(() => {
return Boolean(
currentEvents.value.find((currentEvent) => {
return areDatesIdentical(currentEvent.startDate, tileDate.value)
})
)
})
</script>
<template>
<button
class="relative grid place-items-center aspect-square rounded-full border-2 border-transparent transition-colors after:content-[''] after:absolute after:top-1 after:right-1 after:w-[.3rem] after:h-[.3rem] after:rounded-full after:transition-colors cursor-pointer"
:class="cn({
'text-foreground/75 hover:border-primary hover:text-foreground': !isDefaultDate && !isSelectedDate,
'font-semibold text-foreground bg-secondary hover:border-primary/70': isDefaultDate && !isSelectedDate,
'font-semibold text-primary-foreground bg-primary hover:border-primary/70': isSelectedDate,
'after:bg-primary': hasAtLeastOneEvent,
'after:bg-primary-foreground': hasAtLeastOneEvent && isSelectedDate
})"
@click="selectDate(tileDate)"
>
<span ref="tileRef" class="text-[.85em]">{{ dayNumber }}</span>
</button>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { useCalendar } from "@/stores/CalendarStore"
import { useThrottleFn } from "@vueuse/core"
const { decrementViewYear, incrementViewYear } = useCalendar()
const { sortedMonths: months } = storeToRefs(useCalendar())
function handleWheel(e: WheelEvent) {
const isMovingUp = e.deltaY < 0
if (isMovingUp) {
moveCalendarLeft()
} else {
moveCalendarRight()
}
}
const moveCalendarLeft = useThrottleFn(() => {
decrementViewYear()
}, 100)
const moveCalendarRight = useThrottleFn(() => {
incrementViewYear()
}, 100)
</script>
<template>
<div class="container mx-auto mt-[10vh] mb-auto" @wheel="handleWheel">
<div class="grid grid-cols-5 gap-x-8 gap-y-16">
<CalendarStateYearlyMonthTile
v-for="month in months"
:key="month.id"
:month
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { CalendarMonth } from "@@/models/CalendarMonth";
defineProps<{
month: CalendarMonth
}>()
</script>
<template>
<div>
<div class="font-medium">
{{ month.name }}
</div>
<div class="grid grid-cols-7">
<CalendarStateYearlyDayTile
v-for="day in month.days"
:key="day"
:month-number="month.position"
:day-number="day"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<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>
<div class="flex gap-3 items-center">
<div class="md:hidden">
<SidebarToggle />
</div>
<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>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
type HeadingLevel = "h0" | "h1" | "h2" | "h3"
interface HeadingProps {
level?: HeadingLevel
}
withDefaults(defineProps<HeadingProps>(), {
level: "h2"
})
</script>
<template>
<h1 v-if="level === 'h0'" class="text-4xl md:text-6xl font-bold flex">
<slot />
</h1>
<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-lg md:text-xl font-bold flex">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,39 @@
<template>
<header role="banner" class="sticky top-0 border-b-1 border-border py-4 px-2 z-50 backdrop-blur">
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between">
<div class="md:flex-1 flex justify-start" />
<nav class="md:flex-1">
<menu class="text-sm flex items-center justify-center gap-4">
<li>
<NuxtLink to="/" class="block p-1 font-medium text-foreground/80 dark:text-foreground/70 underline-offset-4 focus-visible:underline hover:underline dark:hover:text-foreground hover:text-foreground transition-colors" active-class="text-primary dark:text-primary">
{{ $t('breadcrumbs.home') }}
</NuxtLink>
</li>
<li>
<NuxtLink to="/explore" class="block p-1 font-medium text-foreground/80 dark:text-foreground/70 underline-offset-4 focus-visible:underline hover:underline dark:hover:text-foreground hover:text-foreground transition-colors" active-class="text-primary dark:text-primary">
{{ $t('pages.explore.menuLabel') }}
</NuxtLink>
</li>
<li>
<NuxtLink to="/about" class="block p-1 font-medium text-foreground/80 dark:text-foreground/70 underline-offset-4 focus-visible:underline hover:underline dark:hover:text-foreground hover:text-foreground transition-colors" active-class="text-primary dark:text-primary">
{{ $t('pages.about.menuLabel') }}
</NuxtLink>
</li>
</menu>
</nav>
<div class="md:flex-1 flex justify-end">
<UserDashboardLink size="xs" />
</div>
</div>
</div>
</header>
</template>
<style lang="scss" scoped>
header {
background-color: color-mix(in srgb, var(--color-background) 75%, transparent);
}
</style>

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
interface SpacingProps {
size?: "xs" | "sm" | "md" | "lg" | "xlg" | "2xl" | "3xl"
}
const props = defineProps<SpacingProps>()
let spacingClass: string
switch (props.size) {
case "lg":
spacingClass = "space-y-4"
break;
case "md":
default:
spacingClass = "space-y-2"
break;
}
</script>
<template>
<div
:class="cn(spacingClass)"
>
<slot />
</div>
</template>

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,22 @@
<script lang="ts" setup>
import { PhCloudX } from '@phosphor-icons/vue';
</script>
<template>
<UiCard class="w-full h-full flex flex-col border-rose-200 bg-rose-50 dark:border-rose-900 dark:bg-rose-950">
<template v-if="$slots.title">
<UiCardHeader class="flex-row items-center gap-2">
<PhCloudX :size="32"/>
<UiCardTitle>
<slot name="title" />
</UiCardTitle>
</UiCardHeader>
</template>
<template v-if="$slots.content">
<UiCardContent class="grow grid gap-2">
<slot name="content" />
</UiCardContent>
</template>
</UiCard>
</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

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { cn } from "@/lib/utils";
import { type RPGColor, rpgColors } from "@@/models/Color";
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
:id
:class="cn(
['hover:bg-secondary hover:text-primary-foreground'],
{
'h-9': theme === 'subtle'
}
)"
>
<UiSelectValue
:placeholder="$t('ui.colors.selectOne')"
class="bgc"
:class="cn(`element-${model}`)"
/>
</UiSelectTrigger>
<UiSelectContent :position>
<UiSelectGroup>
<UiSelectItem
v-for="color in rpgColors"
:key="color"
:value="color"
class="bgc"
:class="
cn(
`element-${color}`,
)
"
>
{{ $t(`ui.colors.${color}`) }}
</UiSelectItem>
</UiSelectGroup>
</UiSelectContent>
</UiSelect>
</template>

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

@@ -0,0 +1,343 @@
<script lang="ts" setup>
import { useEditor, EditorContent, type Content } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { PhArrowUDownRight, PhArrowUUpLeft, PhListBullets, PhListNumbers, PhQuotes, PhTextB, PhTextItalic, PhTextStrikethrough, PhTextTSlash } from '@phosphor-icons/vue'
const props = defineProps<{
disableInlines?: boolean
disableBlocks?: boolean
}>()
const model = defineModel<any>()
const editor = useEditor({
content: model.value as Content,
extensions: [
StarterKit
],
onUpdate: ({ editor }) => {
model.value = editor.getHTML()
}
})
function clearFormatting() {
editor.value?.chain().focus().unsetAllMarks().run()
editor.value?.chain().focus().clearNodes().run()
}
</script>
<template>
<div v-if="editor">
<!-- Toolbar -->
<div class="flex gap-1.5">
<!-- Inline styles -->
<div class="flex" v-if="!props.disableInlines">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleBold().run()"
:disabled="!editor.can().chain().focus().toggleBold().run()"
:variant="editor.isActive('bold') ? 'default' : 'secondary'"
class="size-7 rounded-e-none"
size="icon"
>
<PhTextB weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.bold') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleItalic().run()"
:disabled="!editor.can().chain().focus().toggleItalic().run()"
:variant="editor.isActive('italic') ? 'default' : 'secondary'"
class="size-7 rounded-none"
size="icon"
>
<PhTextItalic weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.italic') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleStrike().run()"
:disabled="!editor.can().chain().focus().toggleStrike().run()"
:variant="editor.isActive('strike') ? 'default' : 'secondary'"
class="size-7 rounded-s-none"
size="icon"
>
<PhTextStrikethrough weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.strike') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<!-- Block styles -->
<div class="flex" v-if="!props.disableBlocks">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleBulletList().run()"
:variant="editor.isActive('bulletList') ? 'default' : 'secondary'"
class="size-7 rounded-e-none"
size="icon"
>
<PhListBullets weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.ulist') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleOrderedList().run()"
:variant="editor.isActive('orderedList') ? 'default' : 'secondary'"
class="size-7 rounded-none"
size="icon"
>
<PhListNumbers weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.olist') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().toggleBlockquote().run()"
:variant="editor.isActive('blockquote') ? 'default' : 'secondary'"
class="size-7 rounded-s-none"
size="icon"
>
<PhQuotes weight="fill" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.quote') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
<div class="flex">
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="clearFormatting"
variant="secondary"
class="size-7 rounded-e-none"
size="icon"
>
<PhTextTSlash weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.noFormat') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().undo().run()"
:disabled="!editor.can().chain().focus().undo().run()"
variant="secondary"
class="size-7 rounded-none"
size="icon"
>
<PhArrowUUpLeft weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.undo') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiTooltipProvider :delay-duration="250">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
@click.prevent="editor.chain().focus().redo().run()"
:disabled="!editor.can().chain().focus().redo().run()"
variant="secondary"
class="size-7 rounded-s-none"
size="icon"
>
<PhArrowUDownRight weight="bold" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent>
<p>
{{ $t('editor.redo') }}
</p>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</div>
</div>
<EditorContent
:editor
class="overflow-y-auto md:text-sm border-b-1 border-border"
/>
</div>
</template>
<style lang="scss">
.tiptap,
.content-editor {
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
}
/* Basic editor styles */
.tiptap {
padding-block: 0.75rem;
min-height: 7rem;
max-height: 12rem;
overflow-y: auto;
:first-child {
margin-top: 0;
}
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { PhInfo } from '@phosphor-icons/vue';
</script>
<template>
<div class="flex justify-between items-center gap-2">
<div class="grow">
<UserCTA />
</div>
<UiButton size="icon" class="size-8 hover:bg-secondary hover:text-secondary-foreground" variant="ghost" as-child>
<NuxtLink to="/about">
<PhInfo size="18" />
</NuxtLink>
</UiButton>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { PanelsLeftBottom } from 'lucide-vue-next';
</script>
<template>
<menu class="min-w-32 pt-16 flex flex-col gap-0.5">
<li class="w-full">
<UiButton
variant="ghost"
size="sm"
class="h-8 gap-1.5 hover:bg-secondary w-full justify-start"
as-child
>
<NuxtLink to="/my" active-class="active-link" exact-active-class="exact-link">
<PanelsLeftBottom :size="18" />
<span class="text-[.9em]">
{{ $t('ui.sidebarMenu.projects') }}
</span>
</NuxtLink>
</UiButton>
</li>
</menu>
</template>
<style lang="scss" scoped>
.active-link {
color: var(--color-secondary-foreground);
background-color: var(--color-secondary);
&:hover {
background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
}
}
.exact-link {
color: var(--color-primary-foreground);
background-color: color-mix(in srgb, var(--color-primary) 66%, transparent);
cursor: initial;
&:hover {
background-color: color-mix(in srgb, var(--color-primary) 66%, transparent);
}
}
.dark .exact-link {
background-color: var(--color-primary);
}
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { PanelsLeftBottom } from "lucide-vue-next"
import { cn } from "@/lib/utils";
import { PhInfo } from "@phosphor-icons/vue";
const { isSidebarOpened } = storeToRefs(useUiStore())
</script>
<template>
<nav
ref="sidebarRef"
:class="cn(
['max-md:hidden w-fit relative isolate p-2 grid gap-4 grid-rows-[1fr_auto] bg-background justify-center transition-colors'], // Base appearance
['after:opacity-50 after:contrast-125 dark:after:opacity-75'], // After styling
['border-r-[1px] border-r-border dark:border-r-border'], // Colours
)"
>
<SidebarMenu />
<SidebarFooter />
</nav>
<UiSheet v-model:open="isSidebarOpened">
<UiSheetContent side="left" class="p-2 grid gap-4 grid-rows-[auto_1fr]">
<UiSheetHeader />
<SidebarMenu />
<SidebarFooter />
</UiSheetContent>
</UiSheet>
</template>

View File

@@ -0,0 +1,17 @@
export type SidebarMenuActionType = "event-search"
export type SidebarMenuIcon = "universe" | "world"
export interface SidebarMenuItem {
phIcon: SidebarMenuIcon,
phIconWeight?: "regular" | "light" | "fill" | "duotone" | "bold" | "thin"
highlight?: boolean
tooltip: string
action?: SidebarMenuActionType
to?: string
}
export interface SidebarProps {
menuItems: SidebarMenuItem[],
isHome?: boolean
}

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { PhList } from "@phosphor-icons/vue"
const { toggleSidebar } = useUiStore()
</script>
<template>
<UiButton
size="icon"
variant="outline"
class="size-7 border-border hover:bg-secondary hover:text-secondary-foreground"
@click="toggleSidebar"
>
<PhList size="14" weight="light" />
</UiButton>
</template>

View File

@@ -0,0 +1,205 @@
<script lang="ts" setup>
import { computed } from "vue"
import { PhCheckCircle, PhUser, PhLaptop, PhMoon, PhPalette, PhSignIn, PhSignOut, PhSun, PhTranslate, PhUserCircle, PhInfo } from "@phosphor-icons/vue"
import { cn } from "@/lib/utils";
const router = useRouter()
const { auth } = useSupabaseClient()
const user = useSupabaseUser()
const userMeta = computed(() => user.value?.user_metadata)
const profileUrl: string = `${useRequestURL().origin}/my/`
const { locale, setLocale } = useI18n()
const menuOpened = ref<boolean>(false)
function closeMenu() {
menuOpened.value = false
}
watch(user, closeMenu)
async function handleGoogleLogin() {
const { error } = await auth.signInWithOAuth({
provider: "google",
options: {
queryParams: {
access_type: "offline",
prompt: "consent"
},
redirectTo: profileUrl
}
})
if (error) {
console.log(error.message)
}
}
async function handleLogout() {
const { error } = await auth.signOut()
if (error) {
console.log(error.message)
}
}
</script>
<template>
<ClientOnly>
<UiDropdownMenu v-model:open="menuOpened">
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost"
class="w-full justify-start p-2 h-fit hover:text-secondary-foreground hover:bg-secondary"
:class="cn({ 'bg-secondary': menuOpened })"
>
<TransitionGroup name="fade-group" appear>
<div
v-if="user"
class="flex gap-2 items-center"
>
<UiAvatar
id="user-avatar"
size="sm"
class="rounded-sm ring-[.2rem] ring-primary transition-all cursor-pointer"
>
<UiAvatarImage
:src="userMeta?.avatar_url"
:alt="userMeta?.full_name"
referrerpolicy="no-referrer"
/>
<UiAvatarFallback>
{{ $t('ui.sidebarMenu.avatarFallback') }}
</UiAvatarFallback>
</UiAvatar>
<div class="text-xs">
{{ userMeta?.name }}
</div>
</div>
</TransitionGroup>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent class="w-72 p-0 pb-1" :align="'start'" :side="'top'" :side-offset="10" :align-offset="25" :collision-padding="40">
<template v-if="user">
<p class="p-2 text-[.7em] opacity-75">
{{ $t('ui.greeting', { user: user?.email }) }}
</p>
</template>
<template v-else>
<p class="p-2 text-[.7em] opacity-75">
{{ $t('ui.anonymousGreeting') }}
</p>
</template>
<UiDropdownMenuSub>
<UiDropdownMenuSubTrigger class="p-0 rounded-none">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center pointer-events-none">
<PhPalette size="20" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.appearance') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubTrigger>
<UiDropdownMenuPortal>
<UiDropdownMenuSubContent>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': $colorMode.preference === 'dark' })"
@select.prevent="$colorMode.preference = 'dark'"
>
<PhCheckCircle v-if="$colorMode.preference === 'dark'" size="20" weight="fill" />
<PhMoon v-else size="20" />
<span>
{{ $t('ui.dark') }}
</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': $colorMode.preference === 'light' })"
@select.prevent="$colorMode.preference = 'light'"
>
<PhCheckCircle v-if="$colorMode.preference === 'light'" size="20" weight="fill" />
<PhSun v-else size="20" />
<span>
{{ $t('ui.light') }}
</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': $colorMode.preference === 'system' })"
@select.prevent="$colorMode.preference = 'system'"
>
<PhCheckCircle v-if="$colorMode.preference === 'system'" size="20" weight="fill" />
<PhLaptop v-else size="20" />
<span>
{{ $t('ui.system') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
<UiDropdownMenuSub>
<UiDropdownMenuSubTrigger class="p-0 rounded-none">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center pointer-events-none">
<PhTranslate size="20" />
<span>
{{ $t('ui.sidebarMenu.language') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubTrigger>
<UiDropdownMenuPortal>
<UiDropdownMenuSubContent>
<UiDropdownMenuSubContent>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': locale === 'fr' })"
@select.prevent="setLocale('fr')"
>
<PhCheckCircle v-if="locale === 'fr'" size="20" weight="fill" />
<img v-else src="/images/flag-fr.png" width="20" alt="" loading="eager">
<span>Français</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem
class="flex gap-[.5ch] items-center rounded-none transition-colors"
:class="cn({ 'text-emerald-600': locale === 'en' })"
@select.prevent="setLocale('en')"
>
<PhCheckCircle v-if="locale === 'en'" size="20" weight="fill" />
<img v-else src="/images/flag-uk.png" width="20" alt="" loading="eager">
<span>English</span>
</UiDropdownMenuItem>
</UiDropdownMenuSubContent>
</UiDropdownMenuSubContent>
</UiDropdownMenuPortal>
</UiDropdownMenuSub>
<UiDropdownMenuSeparator />
<template v-if="user">
<UiDropdownMenuItem class="flex gap-[.5ch] items-center rounded-none" @click="handleLogout">
<PhSignOut size="20" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.logout') }}
</span>
</UiDropdownMenuItem>
</template>
<UiDropdownMenuItem v-if="!user" class="flex gap-[.5ch] items-center rounded-none" @click="handleGoogleLogin">
<PhSignIn size="18" weight="fill" />
<span>
{{ $t('ui.sidebarMenu.login') }}
</span>
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</ClientOnly>
</template>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '@/components/ui/button'
const { auth } = useSupabaseClient()
const user = useSupabaseUser()
const profileUrl: string = `${useRequestURL().origin}/my/`
interface Props extends PrimitiveProps {
size?: ButtonVariants["size"]
}
defineProps<Props>()
async function handleGoogleLogin() {
const { error } = await auth.signInWithOAuth({
provider: "google",
options: {
queryParams: {
access_type: "offline",
prompt: "consent"
},
redirectTo: profileUrl
}
})
if (error) {
console.log(error.message)
}
}
</script>
<template>
<UiButton v-if="user" :size as-child>
<NuxtLink to="/my">
Dashboard
</NuxtLink>
</UiButton>
<UiButton v-else @click="handleGoogleLogin" :size>
Log in
</UiButton>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from "radix-vue"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { AlertDialogAction, type AlertDialogActionProps } from "radix-vue"
import { cn } from "@/lib/utils"
import { buttonVariants } from "~/components/ui/button/index"
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { AlertDialogCancel, type AlertDialogCancelProps } from "radix-vue"
import { cn } from "@/lib/utils"
import { buttonVariants } from "~/components/ui/button/index"
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogCancel v-bind="delegatedProps" :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/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-[calc(100%-2rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 gap-4 border border-popover-border bg-popover 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-50% data-[state=closed]:slide-out-to-top-48% data-[state=open]:slide-in-from-left-50% data-[state=open]:slide-in-from-top-48% rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

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>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

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="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue"
import { AlertDialogTitle, type AlertDialogTitleProps } from "radix-vue"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AlertDialogTrigger, type AlertDialogTriggerProps } from "radix-vue"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@@ -0,0 +1,9 @@
export { default as AlertDialog } from "@/components/ui/alert-dialog/AlertDialog.vue"
export { default as AlertDialogTrigger } from "@/components/ui/alert-dialog/AlertDialogTrigger.vue"
export { default as AlertDialogContent } from "@/components/ui/alert-dialog/AlertDialogContent.vue"
export { default as AlertDialogHeader } from "@/components/ui/alert-dialog/AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "@/components/ui/alert-dialog/AlertDialogTitle.vue"
export { default as AlertDialogDescription } from "@/components/ui/alert-dialog/AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "@/components/ui/alert-dialog/AlertDialogFooter.vue"
export { default as AlertDialogAction } from "@/components/ui/alert-dialog/AlertDialogAction.vue"
export { default as AlertDialogCancel } from "@/components/ui/alert-dialog/AlertDialogCancel.vue"

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "radix-vue"
import { type AvatarVariants, avatarVariant } from "."
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
size?: AvatarVariants["size"]
shape?: AvatarVariants["shape"]
}>(), {
size: "sm",
shape: "circle",
})
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { AvatarFallback, type AvatarFallbackProps } from "radix-vue"
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { AvatarImage, type AvatarImageProps } from "radix-vue"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>

View File

@@ -0,0 +1,24 @@
import { type VariantProps, cva } from "class-variance-authority"
export { default as Avatar } from "@/components/ui/avatar/Avatar.vue"
export { default as AvatarImage } from "@/components/ui/avatar/AvatarImage.vue"
export { default as AvatarFallback } from "@/components/ui/avatar/AvatarFallback.vue"
export const avatarVariant = cva(
"inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
{
variants: {
size: {
sm: "size-6 text-xs",
base: "size-14 text-2xl",
lg: "size-24 text-5xl",
},
shape: {
circle: "rounded-full",
square: "rounded-md",
},
},
},
)
export type AvatarVariants = VariantProps<typeof avatarVariant>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { type BadgeVariants, badgeVariants } from "."
import { cn } from "@/lib/utils"
const props = defineProps<{
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
import { type VariantProps, cva } from "class-variance-authority"
export { default as Badge } from "@/components/ui/badge/Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

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 { PhArrowsHorizontal } from "@phosphor-icons/vue";
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>
<PhArrowsHorizontal class="size-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 rounded-[1px] hover:text-primary focus-visible:text-primary underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-4', 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 transition-colors sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>

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