Compare commits

..

266 commits

Author SHA1 Message Date
8be948623b ci: only run on push
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
prevents duplicate CIs from running at the same time
2025-07-22 18:55:57 +02:00
82e0e59617
fix: logout not working due to use of outdated HTTP method
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-21 12:33:21 +02:00
b07a0aa5a0 Merge pull request 'Implement invite redemption page' (#54) from invite-page into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #54
Reviewed-by: Twig <git@beaver.mom>
2025-07-20 09:15:52 +00:00
eb2af0f7ec Merge branch 'main' into invite-page
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-20 09:15:22 +00:00
ddf173ee8b
fix: broken cropping tool due to missing imports
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-20 04:57:56 +02:00
6a65b257e0
feat: add basic page for viewing and accepting an invite
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-20 02:34:30 +02:00
c87fffe6c9
feat: add fetchInvite function 2025-07-20 02:33:57 +02:00
08436fdce0
feat: minimize flashbanging that occur during development
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-19 22:44:26 +02:00
c93a1829f8 Merge pull request 'resizable-sidebars' (#49) from resizable-sidebars into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #49
Reviewed-by: JustTemmie <git@beaver.mom>
2025-07-19 06:46:26 +00:00
72701c9aef Merge branch 'main' into resizable-sidebars
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-19 06:46:12 +00:00
3a65cfd10a
fix: ensure avatars don't squish depending on sidebar width
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 08:05:11 +02:00
ad7a6b5bb6
fix: add ellipsis overflow to DM list 2025-07-18 08:01:45 +02:00
49e8c34254
fix: make channel list and DM list width share storage name
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 07:44:07 +02:00
232aec13a5 Merge pull request 'Sort members list' (#45) from sort-members-list into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #45
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-18 05:34:43 +00:00
771c0699a7
Merge remote-tracking branch 'origin/main' into sort-members-list
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-18 07:34:13 +02:00
4d5cd86ec3 Merge pull request 'Require refetching the theme whenever a new version releases' (#43) from force-css-regrab into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #43
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-18 05:32:12 +00:00
3953a754bd
Merge remote-tracking branch 'origin/main' into force-css-regrab
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-18 07:31:53 +02:00
60e7a42f92
feat: convert friends list sidebar into resizable sidebar
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 04:37:49 +02:00
89d0023c07
chore: remove unused variables in channelId view 2025-07-18 04:37:15 +02:00
118f098b46
fix: that the reset context menu option reset resizable sidebar width to the localStorage value rather than the default value
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 04:09:16 +02:00
317ec4bcd6
feat: add back onMounted in channelId view 2025-07-18 04:07:29 +02:00
315258a8d5
style(ui): change width resizer's cursor from col-resize to ew-resize
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 03:57:02 +02:00
8ffe3aa738
feat: rename validation util file to validateUsername
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 03:49:29 +02:00
8d53b2765f
fix: hashPassword calls not working due to util file not matching function call name 2025-07-18 03:49:20 +02:00
4b1db5961a
feat: add channel name tooltip to ChannelEntry component
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-18 03:39:23 +02:00
80df3dd13d
feat: adjust width, min-width, and max-width of channels and members list sidebars and implement saving of widths 2025-07-18 03:38:35 +02:00
883ec0354d ci: add staging images
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-17 15:55:02 +02:00
808cc980a7
feat: update createContextMenu util to require either PointerEvent or MouseEvent and update naming of cursor to pointer
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 23:55:33 +02:00
0697e60ab9
feat: convert channels list and members list sidebars to use resizable sidebar component 2025-07-16 23:54:50 +02:00
b6970ffc1c
style(ui): add border-right to guilds list sidebar 2025-07-16 23:54:19 +02:00
eabe3b3704
feat: remove old(?) CSS for middle-left-column in client layout 2025-07-16 23:53:54 +02:00
c295225c43
chore: rename instances of cursor to pointer in createContextMenu component and ContextMenuItem interface 2025-07-16 23:50:41 +02:00
f1e07bd43c
feat: create resizable sidebar component 2025-07-16 23:49:34 +02:00
5f2267a448 Merge pull request 'Implement password reset + minor changes to button component' (#42) from password-reset into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #42
Reviewed-by: JustTemmie <git@beaver.mom>
2025-07-16 17:47:57 +00:00
52af245fdf Merge branch 'main' into password-reset
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-16 17:46:45 +00:00
688001a482
feat: add login link to reset-password page
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 19:45:17 +02:00
f26c5fd7ce Merge pull request 'Move /me/friends to /me, as the new landing page' (#44) from update-friend-landing-page into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #44
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-16 09:42:38 +00:00
5bd307451d
feat: change wording of password recovery text slightly
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 11:37:04 +02:00
db2a99736a
feat: separate password reset page into two, one for sending the email and one to change the password
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline failed
2025-07-16 11:36:12 +02:00
0c4d42f3c1
style: remove function names from implicit util functions
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 11:27:35 +02:00
f2fcdbf733
Merge branch 'update-friend-landing-page' into sort-members-list 2025-07-16 11:26:16 +02:00
d5b7669291
fix: allow optional argument instead of defaulting to undefined
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-16 11:25:36 +02:00
f6ede67c26
style: seperate out sortUsers and sortMembers
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 10:59:00 +02:00
4229682d69
feat: sort member's list alphabetically
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 10:48:02 +02:00
cbe50dd028
fix: clean code to account for guaranteed array returns 2025-07-16 10:47:50 +02:00
100d5e80eb
style: remove unused import 2025-07-16 10:47:13 +02:00
ea1f032ffc
feat: implemend fetchMyGuilds() api 2025-07-16 10:47:03 +02:00
56ccd61107
style: ensure all api requests that return an array to return an empty array instead of undefined 2025-07-16 10:44:23 +02:00
ce5d65e62d
fix: accidentally getting display name of wrong user in replies
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 10:29:41 +02:00
ab87fb681c
chore: update rest of the codebase to use new getDisplayName() function
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-16 10:27:56 +02:00
ace66980bf
feat: sort friends alphabetically 2025-07-16 10:22:04 +02:00
0f583f085e
style: move /me/friends to /me/index 2025-07-16 10:21:30 +02:00
2ce09e7c7a
fix: ensure both href theme updates result in the same url
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 05:37:44 +02:00
73606b6bc3
fix: remove bad "caching" behaviour
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 05:35:56 +02:00
8a172db3f4
feat: refactor code to require refetching the theme whenever a new version releases
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-16 05:30:11 +02:00
e87edbc967
fix: preferred font not working as intended for default icons
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-16 05:04:59 +02:00
788222967b
style: clean up imports for settings page
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-16 05:03:18 +02:00
bca209ef81
style: remove unused imports 2025-07-16 04:55:30 +02:00
b642deb087 Merge pull request 'Add fallback avatar and guild icons' (#41) from fallback-server-icons into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #41
2025-07-16 02:51:52 +00:00
2381960277
fix: incorrect merge
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-16 04:51:18 +02:00
29243aa86f
Merge remote-tracking branch 'origin/main' into fallback-server-icons 2025-07-16 04:49:59 +02:00
6d51fa5889 Merge pull request 'Fix sidebar and make it configurable thru themes' (#40) from fix-sidebar into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #40
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-16 02:43:33 +00:00
97bc6c45a9
Merge remote-tracking branch 'origin/main' into fix-sidebar
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-16 04:42:55 +02:00
ccd37a2fc3 Merge pull request 'add IRC colours' (#36) from irc-colours into main
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
Reviewed-on: #36
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-16 02:42:47 +00:00
68cb7438ce
Merge remote-tracking branch 'origin/main' into irc-colours
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-16 04:41:52 +02:00
07fa883a14
feat: change Avatar id to class
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-15 11:31:11 +02:00
4ce89d9803
feat: update wording of guild create/join button's alt text
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-15 11:24:27 +02:00
32910d2077
feat: improve theming
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-15 01:13:10 +02:00
63dbfa2a0d
fix: ACTUALLY fix font theming 2025-07-15 00:44:41 +02:00
64131e6f9c
fix: set back font defaults 2025-07-15 00:36:54 +02:00
186d3c7a0a
feat: add preferred font field to themes
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-15 00:25:52 +02:00
a146eb001a
style: manually edit the reply svg a bit
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-15 00:16:47 +02:00
17791fc017
feat: implement resetting of password if forgotten
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-14 22:28:40 +02:00
42ed743054
feat: add functions to send password reset email and actual password reset in api composable 2025-07-14 22:28:15 +02:00
491e736422
feat: change Button component to be a button and not a div, and made callback optional 2025-07-14 22:27:30 +02:00
26243a8cd6
fix: colours not working for guild canvas-less fallback
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-14 22:27:29 +02:00
890c479f2c
style: minor style changes
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-14 21:56:57 +02:00
b81cd2b73a
feat: improve guild icon fallbacks for blocked canvases 2025-07-14 21:55:10 +02:00
dfec4c9200
fix: properly support blocked canvases for avatars 2025-07-14 21:46:18 +02:00
cbc010943c
chore: minor code cleanup
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-14 21:39:42 +02:00
e7558d9a95
refactor: move MemberEntry 2025-07-14 21:39:18 +02:00
f98e8c6110
feat: implement generic <Avatar> component 2025-07-14 21:39:00 +02:00
f4ddcf9d8d
fix: prop 2025-07-14 21:37:45 +02:00
b319a06749
feat: import function from JOHN OZBAY 2025-07-14 21:36:41 +02:00
25cd9a397e
feat: implement caching for hash function 2025-07-14 20:05:31 +02:00
bbc822604f
Merge branch 'irc-colours' into fallback-server-icons 2025-07-14 19:51:12 +02:00
9bfe3310cc
fix: comply with es2020 standards
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-14 19:48:35 +02:00
06de4777f9
feat: make sidebar size adjustable by theme
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-14 19:36:16 +02:00
df741ee5d4
fix: sidebar scrolling, and such 2025-07-14 19:24:59 +02:00
dac473e9fb Merge branch 'main' of ssh://git.gorb.app:2022/gorb/frontend
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-14 18:45:18 +02:00
fc87bd4b6f
chore: remove unused temporary members list array 2025-07-14 18:44:25 +02:00
9d1eeff582
feat: remove -ms-overflow-style CSS property from left column 2025-07-14 18:44:18 +02:00
9f914de77b Merge pull request 'feat: implement image embeds' (#39) from media-embed into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #39
Reviewed-by: JustTemmie <git@beaver.mom>
2025-07-14 16:29:23 +00:00
015b23f4e5
feat: implement image embeds
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-14 01:11:36 +02:00
b6b8d10d29
fix: automatically scrolling to bottom of chat not working properly
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-14 01:07:46 +02:00
480c91d419
feat: use v-if instead of programmatically rendering invite modal in GuildOptionsMenu 2025-07-14 01:06:42 +02:00
088c6c558b
fix: modals not showing properly due to imports not being updated after components folder restructuring 2025-07-14 01:05:47 +02:00
7f1b26a59c
style(ui): hide scrollbar in left column
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-13 20:48:28 +02:00
047fe5e833
fix: links not being clickable due to href attribute not being allowed 2025-07-13 20:47:55 +02:00
926a751e0c Merge pull request 'chore: sort components into subfolders' (#37) from sort-components into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #37
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-13 18:40:07 +00:00
86ddae62b2
chore: sort components into subfolders
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-13 20:34:59 +02:00
dc786cd420
fix: remove random console log
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
ci/woodpecker/pr/build-and-publish Pipeline failed
2025-07-13 18:17:01 +02:00
9b7de48c02
feat: add IRC colours, without a toggle for now
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
2025-07-13 18:16:02 +02:00
be5d65883b
feat: add xxhash-wasm library 2025-07-13 18:15:48 +02:00
2299d3a17a Merge pull request 'guild-settings' (#35) from guild-settings into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #35
2025-07-13 02:26:37 +00:00
b2eb80a15f Merge branch 'main' into guild-settings
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-13 02:26:14 +00:00
6cec8e92b3
feat: add handler for removing elements with destroy-on-click class upon clicking anywhere else on the screen
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline failed
2025-07-13 04:20:26 +02:00
569bca810e
style(ui): change styling of left column so it includes guild creation and joining button 2025-07-13 04:19:50 +02:00
e27b1cfc9d
chore: switch hardcoded colors to existing color variables in client layout 2025-07-13 04:17:27 +02:00
690ef5ef02
fix: Button components not rendering due to missing import in client layout 2025-07-13 04:16:44 +02:00
efaf606c3c
feat: create channel called general when creating a guild 2025-07-13 04:16:17 +02:00
06df5cf75d
style(ui): improve styling of guild creation and joining modals and switch to using Button components in client layout 2025-07-13 04:15:51 +02:00
11e46049a0
chore: add dropdown- prefix to button class and switch colors to using existing color variables in Dropdown component 2025-07-13 04:14:39 +02:00
4adba889e4
style(ui): make button height and width 100% and use existing variables for colors in GuildOptionsMenu 2025-07-13 04:13:29 +02:00
7959f702c6
feat: rename guild-options CSS class and add destroy-on-click class to GuildOptionsMenu top div 2025-07-13 04:12:29 +02:00
f83b3f34d8
feat: redesign InviteModal 2025-07-13 04:11:06 +02:00
c2ae978ec1
feat: redesign Modal component and add exit button 2025-07-13 04:10:49 +02:00
d43105ab58
feat: make createGuild in api composable return GuildResponse or undefined instead of void 2025-07-13 04:10:15 +02:00
a164f89042
feat: change unrender function to take in any Element rather than HTMLElement 2025-07-13 04:09:52 +02:00
287a6415c9
feat: rename ModalProps heavy property to obscure 2025-07-13 04:09:24 +02:00
ff4e792fbb
fix: time on the left of messages not following your prefered time format
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-13 02:17:23 +02:00
cf32b62ae7
Merge branch 'guild-settings' into guild-joining
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-13 01:45:05 +02:00
76bef24a9a
Merge branch 'main' into guild-settings
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
2025-07-13 01:05:24 +02:00
21fcbc8cac
feat: implement guild join and creation buttons
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-13 00:59:40 +02:00
58518876bf
style: improve style of Dropdown component 2025-07-13 00:58:11 +02:00
8695221950
feat: switch to em for guild option setting height 2025-07-13 00:57:46 +02:00
7ae7bc6d76
feat: programmatically create invite modal 2025-07-13 00:56:47 +02:00
e73df90310
feat: make guild settings buttons selectable by tab 2025-07-13 00:54:54 +02:00
9293a48394
feat: remove hardcoded InviteModal from GuildOptionsMenu 2025-07-13 00:54:07 +02:00
9d49012fb9
feat: remove Settings option from GuildOptionsMenu 2025-07-13 00:51:06 +02:00
68573f1262
feat: add function for unrendering/removing components created with h() 2025-07-13 00:46:37 +02:00
a2a28f9dbf
fix: not being able to get guild ID from route due to it being in a component 2025-07-13 00:42:07 +02:00
1dfb964dd2
feat: add buttons for copying invite in link and plain code forms 2025-07-13 00:40:20 +02:00
6578b95704
feat: handle close and cancel events in Modal component 2025-07-13 00:24:28 +02:00
d3aeccf3f9
feat: remove temporary loop padding guild list 2025-07-13 00:20:49 +02:00
3c868931e8
feat: add createChannel function to api composable 2025-07-13 00:20:06 +02:00
a1e21244aa
feat: add joinGuild function to api composable 2025-07-13 00:19:45 +02:00
94a37340f6
feat: add createGuild function to api composable 2025-07-13 00:18:21 +02:00
fb452d8a5b
feat: add and use ModalProps interface 2025-07-13 00:04:19 +02:00
6071bbce35
feat: make it so channels and members are re-fetched on each activation 2025-07-12 23:59:23 +02:00
923bb09905
style: set colour, not background colour
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 23:29:27 +02:00
1ff8d02a86
style: add hover colour to setting menu
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 23:27:33 +02:00
ccd1546376
style: change settings icon to use primary colour
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 23:05:17 +02:00
9db9041048
fix: remove unused functions
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 22:59:32 +02:00
36f21f7ff5
fix: gorb marquee not being part of page flexbox
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 22:53:48 +02:00
13d4369c48 Merge pull request 'Add support for 12 and 24 hour time formats (and add radio buttons)' (#33) from time-format into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #33
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-12 20:48:35 +00:00
0a8ae5fe31
fix: move radio buttons to subfolder
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-12 22:48:11 +02:00
885fc5f906
fix: PR complaints
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 22:40:54 +02:00
195322f3b0
feat: make settings typed and store "12" and "24" over "12-hour" and "24-hour" internally 2025-07-12 22:39:26 +02:00
6221359a15
style(ui): move homebar to app.vue outside <NuxtPage /> to avoid it being rerendered on route change
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 22:20:28 +02:00
d18f00d1c0 Merge pull request 'refactor how client settings are saved and loaded' (#32) from client-settings-refactor into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #32
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-12 19:59:41 +00:00
3e0ce16cce
Merge remote-tracking branch 'origin/main' into client-settings-refactor
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-12 21:58:49 +02:00
eb49450756
feat: support 12 and 24 hour formats
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 20:43:25 +02:00
de6c9bb7eb
feat: finish radio buttons 2025-07-12 20:35:05 +02:00
562409b660
fix: move radio buttons.vue into UI folder 2025-07-12 19:34:34 +02:00
963da24046
Merge remote-tracking branch 'origin/main' into time-format 2025-07-12 19:32:04 +02:00
1d1cfa0af2 Merge pull request 'Implement friends list and refactor components' (#28) from friends-list into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #28
Reviewed-by: Radical <radical@radical.fun>
Reviewed-by: SauceyRed <saucey@saucey.red>
2025-07-12 17:28:42 +00:00
b0e56e1a06
fix: add friends by username, not ID
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-12 19:28:17 +02:00
457405186a
Merge branch 'main' into friends-list
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 19:27:19 +02:00
3fc8933b1e
fix: VerticalSpacer being referred to as verticalSpacer
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 19:24:38 +02:00
0d6786ffe9
fix: add missing return calls to add and remove friend 2025-07-12 19:21:43 +02:00
b731228fb8
feat: add friend count to buttons on friend page
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 19:14:18 +02:00
f5457f9965
fix: remove "N/A online" from all friends page 2025-07-12 19:11:47 +02:00
83464c8f13
fix: button variants not displaying properly 2025-07-12 19:08:25 +02:00
890fbebbe9
feat: use <style module> for friends list-scoped css
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 18:58:09 +02:00
79aa61cb81
chore: remove unused css 2025-07-12 18:57:21 +02:00
010964f188
feat: convert links with hashes to buttons with filter var, and loop through buttons to create 2025-07-12 18:56:14 +02:00
2cc42a729b
fix: rename selectedThemeUrl to selectedThemeId, as they're storing IDs
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-12 18:53:02 +02:00
87a5b99e50
feat: add radio buttons and start integrating them into time format setting
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 18:49:28 +02:00
9256f9326b
style(ui): add svg to connect reply message sender to the reply preview in chat
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 18:49:25 +02:00
ba8abee256
style(ui): adjust margin of message replies in chat
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-12 17:55:16 +02:00
f226ba2364
style(themes): add and use --reply-text-color 2025-07-12 17:52:34 +02:00
6752b44e95
fix: refresh loop once access token expires caused by attempt to revoke auth 2025-07-12 17:48:57 +02:00
d9aef4eb3a
feat: remove export of accessToken and add export of clearAuth in auth composable 2025-07-12 17:47:55 +02:00
5b4c278b83
refactor: load and save settings from a single object
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-11 23:48:03 +02:00
cd1f294600
feat: change color of reply background and add highlight color
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-11 04:12:29 +02:00
51d8909a51
feat: change color of reply message text and remove bottom margin
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-11 04:10:16 +02:00
c06c732afb
fix: replies being highlighted even if the replies weren't for the logged-in user 2025-07-11 04:09:26 +02:00
8812bf7f40 Merge pull request 'fix: replies displaying replier's name rather than the name of the user being replied to' (#31) from replies into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #31
2025-07-11 01:54:27 +00:00
8c46f78dd3 Merge branch 'main' into replies
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-11 01:54:19 +00:00
fdabe96a68
fix: replies displaying replier's name rather than the name of the user being replied to
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline failed
2025-07-11 03:52:29 +02:00
5b0c98bb20 Merge pull request 'Implement Replies' (#29) from replies into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #29
2025-07-11 01:39:44 +00:00
6e74481891 Merge branch 'main' into replies
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-11 01:39:18 +00:00
daa13dbbed
feat: add global listener for Escape key to remove message reply box
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline failed
2025-07-11 03:33:45 +02:00
a8ee8122ee
feat: implement message replies 2025-07-11 03:33:24 +02:00
2ff892b0da
feat: add util to create MessageReply instance 2025-07-11 03:31:25 +02:00
53c2f93791
feat: add MessageReply component 2025-07-11 03:30:51 +02:00
21f441bccf
feat: add properties for whether a message is a reply or user is mentioned and message response to MessageProps 2025-07-11 03:30:15 +02:00
84b7c01251
feat: include new reply_to property in MessageResponse interface 2025-07-11 03:28:50 +02:00
c2b06f40b4
fix: align textbox to bottom of the screen
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-11 02:34:08 +02:00
edb6c01b52
chore: "why the question mark after the username"
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
co-authored-by: Radical <radical@radical.fun>
2025-07-11 01:55:42 +02:00
4e2e61d4dc
chore: remove unnecessary code
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-11 01:54:24 +02:00
0562127e4a
feat: finish entire friends menu
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-11 01:45:24 +02:00
5958697b6c
Merge branch 'main' into replies 2025-07-11 00:10:01 +02:00
292bd64ed4
feat: add runCallback function to handle closing removal of context menu before running callback
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-11 00:05:59 +02:00
069d3392d2
feat: improve context menu handling in app.vue 2025-07-11 00:05:20 +02:00
64b10b48aa
feat: switch to using MessageProps interface in Message component 2025-07-11 00:04:18 +02:00
4f4063fa88
feat: make both message div types use ref and add contextmenu listeners to message divs 2025-07-11 00:03:42 +02:00
c746f816d8
feat: add author and logged-in user (me) as props for Message component 2025-07-11 00:02:09 +02:00
b28920898c
feat: add utils to reply to and edit messages 2025-07-10 23:58:00 +02:00
b51efc01e9
feat: add utils to create and remove the context menu 2025-07-10 23:57:25 +02:00
0ea9c8f168
feat: update ContextMenu props var to use new interface and move setting of menuItems to Message component 2025-07-10 23:56:28 +02:00
11802040bd
feat: add ContextMenuItem interface 2025-07-10 23:55:26 +02:00
34976b4f50
feat: finish DM sidebar 2025-07-10 23:12:44 +02:00
c9bea94ef8
fix: add missing imports 2025-07-10 23:03:46 +02:00
a3feb07e73
refactor: rename Channel to ChannelEntry 2025-07-10 23:00:54 +02:00
7b62d352f8
fix: user verticalSpacer over existing spans 2025-07-10 22:52:16 +02:00
8e69dc805e
chore: remove unused popups 2025-07-10 22:51:56 +02:00
59000709fe
feat: update api 2025-07-10 22:51:14 +02:00
15e5a21277
refactor: try sorting components into sub-folders
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
2025-07-10 22:47:52 +02:00
5dbf21b0ab
feat: implement friends list 2025-07-10 22:44:46 +02:00
8a9ecaa2e2
refactor: move spacer into it's own component 2025-07-10 22:44:18 +02:00
b1a3ce9b00
feat: update interface to include friends_since 2025-07-10 22:18:28 +02:00
a90f062181
refactor: move the homepage to /me from /
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-10 21:53:25 +02:00
71242d0543 revert 04358e9c91
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
revert feat: make settings menu remember your page on reload

it breaks the back button, my bad
2025-07-10 19:37:04 +00:00
04358e9c91
feat: make settings menu remember your page on reload
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-10 21:04:27 +02:00
f59162bad5
fix: width of icon and channel lists
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-10 20:13:25 +02:00
2790772cb7
fix: some of the worst merging i've done in my life
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-10 16:32:41 +02:00
97ad3155c3
Merge remote-tracking branch 'origin/multiline-message-box'
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-10 16:25:36 +02:00
7ffe543f44
fix: invalid css 2025-07-10 16:24:10 +02:00
a9b0eda9e2
Merge remote-tracking branch 'origin/ui-refactor'
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed
2025-07-10 16:19:21 +02:00
92c0024411
Merge branch 'ui-refactor' 2025-07-10 16:18:35 +02:00
c3f3c95af6
Merge branch 'comedical-main-bar' 2025-07-10 16:17:29 +02:00
53dfdd8f9d
Merge branch 'home-page' 2025-07-10 16:16:48 +02:00
91d2e45559
Merge remote-tracking branch 'origin/improve-theming' 2025-07-10 16:16:34 +02:00
323178af6b
undo the last 6 merges i fucked up
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-10 16:10:55 +02:00
0afb269788
feat: start adding back button for settings 2025-07-10 15:48:14 +02:00
a1cd9c482c
feat: increase width of channels and members lists
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-10 11:46:25 +02:00
b67d568c5e fix: display names with empty string not being replaced by usernames in member list
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-09 20:00:45 +02:00
7ff2b35ae2 Merge pull request 'Implement email verification' (#26) from email-verification into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #26
2025-07-09 17:54:33 +00:00
822b16ae07 Merge branch 'main' into email-verification
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-09 17:53:35 +00:00
704de034b7
feat(wip): start working on seeing if emitting events between component and parent works
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-09 07:40:20 +02:00
fef618795d
feat: add custom context handling 2025-07-09 07:39:42 +02:00
950d27b2cf
feat(wip): add custom context menu 2025-07-09 07:38:50 +02:00
7ddc2acb02
feat: add message id as data field of Message component 2025-07-09 07:37:05 +02:00
4b6dc03b13
feat: refresh page when email is changed and instance requires verification
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline failed
2025-07-09 07:30:46 +02:00
84aa1f95fe
feat: add auto redirection in verify-email page 2025-07-09 07:30:06 +02:00
2c670ebc6a
feat: add email verification handling in register page 2025-07-09 07:29:03 +02:00
2863651f70
feat: switch to use fetcvhInstanceStats from api in login page to check for whether registration is enabled 2025-07-09 07:28:18 +02:00
9f66643b99
feat: add attempting to set instance URL from auth middleware 2025-07-09 07:27:44 +02:00
dd63095526
feat: add check for email verification requirement and redirect if needed in auth middleware 2025-07-09 07:27:16 +02:00
e95e81112b
feat: improve looks of auth page 2025-07-09 07:25:57 +02:00
9aec279d05
feat: refresh page when auth tokens are cleared 2025-07-09 07:25:39 +02:00
9e5d48931d
feat: switch to use fetcvhInstanceStats from api composable in auth.vue 2025-07-09 07:25:10 +02:00
6154bb89d0
feat: change some console.log calls 2025-07-09 07:23:51 +02:00
397e94798f
feat: remove unused type import 2025-07-09 07:21:49 +02:00
04689e57ea
feat: remove old logout and other auth functions from login/register page 2025-07-09 07:21:16 +02:00
5c178f99ae
feat: add fetchInstanceStats and sendVerificationEmail to api composable 2025-07-09 07:19:40 +02:00
a6572c20f9
fix: remove accidental duplication of member list
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
this was for local testing, forgot to remove it
2025-07-09 01:04:37 +02:00
5cdeb36200
refactor: more dv to em
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-09 00:46:59 +02:00
9a2fe8fb37
refactor: change a lot of dynamic sizes to em and rem
this makes the client feel a LOT snappier
2025-07-09 00:43:23 +02:00
730b0cb1dc
refactor: change the client from table to flexbox
this makes the server, channel, and member list a constant size, making the text messages take up the entire remaining width
this also fixes the text wrapping you have already fixed on one of your branches

this change is required if we want to make the member list toggelable, or channel list resizable
2025-07-09 00:25:52 +02:00
8932070fcc
feat: spice up the home bar
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-08 21:11:14 +02:00
bd6307f16e
fix: rephrase sentace from "server" to "guild"
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
ci/woodpecker/pull_request_closed/build-and-publish Pipeline was successful
2025-07-08 21:00:05 +02:00
bb5ff37509
fix: put the home page into index.vue instead
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
ci/woodpecker/pr/build-and-publish Pipeline was successful
2025-07-08 20:58:40 +02:00
19113f1303
feat: implement basic home page
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-08 20:57:40 +02:00
1e0b8e2ba1
feat: add Dropdown component 2025-07-07 21:07:33 +02:00
a111180b52
feat: add word wrapping in messages
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-07 20:23:47 +02:00
0c6cae110f
feat: add more restrictions to markdown sanitization
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-07 19:53:50 +02:00
7dcd80cdf7
feat: implement invite modal in guild options menu
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-07-07 11:36:16 +02:00
21cb1c37df
feat: create Modal component, rename InvitePopup to InviteModal and add invite generation 2025-07-07 11:35:16 +02:00
a551cd547d
feat: remove InvitePopup from channelId view 2025-07-07 11:33:34 +02:00
4dea7d27db
feat: start work on Modal component 2025-06-07 06:29:31 +02:00
80945f1177
feat: start work on Banner component 2025-06-07 06:29:15 +02:00
b82d5733a1
feat: refactor left column in UI, add join guild icon 2025-06-07 06:28:07 +02:00
64c6276153
feat: add dropdown for guild settings and invite 2025-06-07 06:25:51 +02:00
81 changed files with 2605 additions and 415 deletions

View file

@ -8,7 +8,6 @@ steps:
- pnpm build
when:
- event: push
- event: pull_request
- name: container-build-and-publish
image: docker
@ -23,3 +22,17 @@ steps:
when:
- branch: main
event: push
- name: container-build-and-publish (staging)
image: docker
commands:
- docker login --username radical --password $PASSWORD git.gorb.app
- docker buildx build --platform linux/amd64,linux/arm64 --rm --push -t git.gorb.app/gorb/frontend:staging .
environment:
PASSWORD:
from_secret: docker_password
volumes:
- /var/run/podman/podman.sock:/var/run/docker.sock
when:
- branch: staging
event: push

62
app.vue
View file

@ -6,31 +6,61 @@
</template>
<script lang="ts" setup>
import loadPreferredTheme from '~/utils/loadPreferredTheme';
const banner = useState("banner", () => false);
let currentTheme = "dark" // default theme
const savedTheme = localStorage.getItem("selectedTheme");
if (savedTheme) {
currentTheme = savedTheme;
onMounted(() => {
loadPreferredTheme()
document.removeEventListener("contextmenu", contextMenuHandler);
document.addEventListener("contextmenu", (e) => {
if (e.target instanceof Element && e.target.classList.contains("default-contextmenu")) return;
contextMenuHandler(e);
});
document.addEventListener("mousedown", (e) => {
if (e.target instanceof HTMLDivElement && e.target.closest("#context-menu")) return;
console.log("click");
console.log("target:", e.target);
console.log(e.target instanceof HTMLDivElement);
removeContextMenu();
if (e.target instanceof HTMLElement && e.target.classList.contains("message-text") && e.target.contentEditable) {
e.target.contentEditable = "false";
}
const destroyOnClick = document.getElementsByClassName("destroy-on-click");
for (const element of destroyOnClick) {
const closest = (e.target as HTMLElement).closest(".destroy-on-click");
if (element != closest) {
unrender(element);
}
}
});
document.addEventListener("keyup", (e) => {
const messageReply = document.getElementById("message-reply") as HTMLDivElement;
if (e.key == "Escape" && messageReply) {
e.preventDefault();
messageReply.remove();
}
});
});
function contextMenuHandler(e: MouseEvent) {
e.preventDefault();
//console.log("Opened context menu");
//createContextMenu(e, [
// { name: "Wah", callback: () => { return } }
//]);
}
const baseURL = useRuntimeConfig().app.baseURL;
useHead({
link: [
{
rel: "stylesheet",
href: `${baseURL}themes/${currentTheme}.css`
}
]
})
</script>
<style>
html,
html {
background-color: #1f1e1d;
}
body {
font-family: Arial, Helvetica, sans-serif;
font-family: var(--preferred-font), Arial, Helvetica, sans-serif;
box-sizing: border-box;
color: var(--text-color);
background: var(--optional-body-background);

44
components/Avatar.vue Normal file
View file

@ -0,0 +1,44 @@
<template>
<NuxtImg v-if="displayAvatar"
class="display-avatar"
:src="displayAvatar"
:alt="displayName" />
<Icon v-else
name="lucide:user"
:alt="displayName" />
</template>
<script lang="ts" setup>
import { NuxtImg } from '#components';
import type { GuildMemberResponse, UserResponse } from '~/types/interfaces';
const props = defineProps<{
user?: UserResponse,
member?: GuildMemberResponse,
}>();
let displayName: string
let displayAvatar: string | null
const user = props.user || props.member?.user
if (user) {
displayName = getDisplayName(user, props.member)
if (user.avatar) {
displayAvatar = user.avatar
} else if (!isCanvasBlocked()){
displayAvatar = generateDefaultIcon(displayName, user.uuid)
} else {
displayAvatar = null
}
}
</script>
<style scoped>
.display-avatar {
border-radius: var(--pfp-radius);
}
</style>

13
components/Banner.vue Normal file
View file

@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

View file

@ -1,10 +1,10 @@
<template>
<div v-if="isCurrentChannel" class="channel-list-link-container rounded-corners current-channel" tabindex="0">
<div v-if="isCurrentChannel" class="channel-list-link-container rounded-corners current-channel" tabindex="0" :title="props.name">
<NuxtLink class="channel-list-link" :href="props.href" tabindex="-1">
# {{ props.name }}
</NuxtLink>
</div>
<div v-else class="channel-list-link-container rounded-corners" tabindex="0">
<div v-else class="channel-list-link-container rounded-corners" tabindex="0" :title="props.name">
<NuxtLink class="channel-list-link" :href="props.href" tabindex="-1">
# {{ props.name }}
</NuxtLink>
@ -23,14 +23,16 @@ const isCurrentChannel = props.uuid == props.currentUuid;
.channel-list-link {
text-decoration: none;
color: inherit;
padding-left: .5dvw;
padding-right: .5dvw;
padding-left: .25em;
padding-right: .25em;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-list-link-container {
text-align: left;
display: flex;
height: 4dvh;
height: 1.5em;
white-space: nowrap;
align-items: center;
}

View file

@ -0,0 +1,46 @@
<template>
<div class="dropdown-body">
<div v-for="option of props.options" class="dropdown-option">
<button class="dropdown-button" :data-value="option.value" @click.prevent="option.callback" tabindex="0">{{ option.name }}</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { DropdownOption } from '~/types/interfaces';
const props = defineProps<{ options: DropdownOption[] }>();
</script>
<style scoped>
.dropdown-body {
position: absolute;
z-index: 100;
left: 4dvw;
bottom: 4dvh;
background-color: var(--chat-background-color);
width: 8rem;
display: flex;
flex-direction: column;
}
.dropdown-option {
border: .09rem solid rgb(70, 70, 70);
}
.dropdown-button {
padding-top: .5dvh;
padding-bottom: .5dvh;
color: var(--text-color);
background-color: transparent;
width: 100%;
border: none;
}
.dropdown-button:hover {
background-color: var(--padding-color);
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<div id="guild-options-menu" class="destroy-on-click">
<div v-for="setting of settings" class="guild-option" tabindex="0">
<button class="guild-option-button" @click="setting.action" tabindex="0">{{ setting.name }}</button>
</div>
</div>
<ModalInvite v-if="showInviteModal" :guild-id="guildId" />
</template>
<script lang="ts" setup>
const settings = [
{ name: "Invite", icon: "lucide:letter", action: openInviteModal }
]
const guildId = useRoute().params.serverId as string;
const showInviteModal = ref(false);
function openInviteModal() {
showInviteModal.value = true;
}
</script>
<style>
#guild-options-menu {
display: flex;
flex-direction: column;
position: relative;
background-color: var(--chat-background-color);
top: 8dvh;
z-index: 10;
width: 100%;
position: absolute;
}
.guild-option {
display: flex;
justify-content: center;
align-items: center;
height: 2em;
box-sizing: border-box;
}
.guild-option:hover {
background-color: var(--padding-color);
}
.guild-option-button {
border: 0;
background-color: transparent;
color: var(--main-text-color);
height: 100%;
width: 100%;
}
</style>

View file

@ -1,8 +1,7 @@
<template>
<div class="member-item" @click="togglePopup" @blur="hidePopup" tabindex="0">
<img v-if="props.member.user.avatar" class="member-avatar" :src="props.member.user.avatar" :alt="props.member.user.display_name ?? props.member.user.username" />
<Icon v-else class="member-avatar" name="lucide:user" />
<span class="member-display-name">{{ props.member.user.display_name ?? props.member.user.username }}</span>
<Avatar :member="props.member" class="member-avatar"/>
<span class="member-display-name">{{ getDisplayName(props.member.user, props.member) }}</span>
<UserPopup v-if="isPopupVisible" :user="props.member.user" id="profile-popup" />
</div>
</template>
@ -10,7 +9,6 @@
<script lang="ts" setup>
import { ref } from 'vue';
import type { GuildMemberResponse } from '~/types/interfaces';
import UserPopup from './UserPopup.vue';
const props = defineProps<{
member: GuildMemberResponse

View file

@ -1,40 +0,0 @@
<template>
<div id="invite-popup">
<div v-if="invite">
<p>{{ invite }}</p>
<button @click="copyInvite">Copy Link</button>
</div>
<div v-else>
<button @click="generateInvite">Generate Invite</button>
</div>
</div>
</template>
<script lang="ts" setup>
import type { InviteResponse } from '~/types/interfaces';
const invite = ref<string>();
const route = useRoute();
async function generateInvite(): Promise<void> {
const createdInvite: InviteResponse | undefined = await fetchWithApi(
`/guilds/${route.params.serverId}/invites`,
{ method: "POST", body: { custom_id: "oijewfoiewf" } }
);
invite.value = createdInvite?.id;
return;
}
function copyInvite() {
const inviteUrl = URL.parse(`invite/${invite.value}`, `${window.location.protocol}//${window.location.host}`);
navigator.clipboard.writeText(inviteUrl!.href);
}
</script>
<style>
</style>

View file

@ -0,0 +1,63 @@
<template>
<div style="text-align: left;">
<h3>Add a Friend</h3>
Enter a friend's Gorb username to send them a friend request.
</div>
<div id="add-friend-search-bar">
<input id="add-friend-search-input" ref="inputField"
placeholder="blahaj.enjoyer" maxlength="32" @keypress.enter="sendRequest"/> <!-- REMEMBER TO CHANGE THIS WHEN WE ADD FEDERATION-->
<Button id="friend-request-button" :callback="sendRequest" text="Send Friend Request"></Button>
</div>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const inputField = ref<HTMLInputElement>();
const { addFriend } = useApi();
async function sendRequest() {
if (inputField.value) {
try {
await addFriend(inputField.value.value)
alert("Friend request sent!")
} catch {
alert("Request failed :(")
}
}
}
</script>
<style>
#add-friend-search-bar {
display: flex;
text-align: left;
margin-top: .8em;
padding: .3em .3em;
border-radius: 1em;
border: 1px solid var(--accent-color);
}
#add-friend-search-input {
border: none;
box-sizing: border-box;
margin: 0 .2em;
flex-grow: 1;
color: inherit;
background-color: unset;
font-weight: medium;
letter-spacing: .04em;
}
#add-friend-search-input:empty:before {
content: attr(placeholder);
color: gray;
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<ResizableSidebar width="14rem" min-width="8rem" max-width="30rem" border-sides="right" local-storage-name="middleLeftColumn">
<div id="middle-left-column">
<div id="friend-sidebar">
<div>
<h3>Direct Messages</h3>
</div>
<VerticalSpacer />
<NuxtLink class="user-item" :href="`/me`" tabindex="0">
<Icon class="user-avatar" name="lucide:user" />
<span class="user-display-name">Friends</span>
</NuxtLink>
<VerticalSpacer />
<div id="direct-message-list">
<UserEntry v-for="user of friends" :user="user"
:href="`/me/${user.uuid}`"/>
</div>
</div>
</div>
</ResizableSidebar>
</template>
<script lang="ts" setup>
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
import ResizableSidebar from '../UserInterface/ResizableSidebar.vue';
const { fetchFriends } = useApi();
const friends = await fetchFriends()
</script>
<style>
#middle-left-column {
background: var(--optional-channel-list-background);
background-color: var(--sidebar-background-color);
}
#friend-sidebar {
padding-left: .5em;
padding-right: .5em;
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<input id="search-friend-bar" placeholder="search"/>
<!-- we aren't checking for the "all" variant, since this is the default and fallback one -->
<p v-if="props.variant === 'online'" style="text-align: left;">Online 0</p>
<p v-else-if="props.variant === 'pending'" style="text-align: left;">Friend Requests 0</p>
<p v-else style="text-align: left;">Friends {{ friends?.length || 0 }}</p>
<div id="friends-list">
<div v-if="props.variant === 'online'">
Not Implemented
</div>
<div v-else-if="props.variant === 'pending'">
Not Implemented
</div>
<div v-else>
<UserEntry v-for="user of friends" :user="user" :name="getDisplayName(user)"
:href="`/me/${user.uuid}`"/>
</div>
</div>
</template>
<script lang="ts" setup>
const { fetchFriends } = useApi();
const friends = sortUsers(await fetchFriends())
const props = defineProps<{
variant: string
}>();
</script>
<style>
#search-friend-bar {
text-align: left;
margin-top: .8em;
padding: .3em .5em;
width: 100%;
border-radius: 1em;
border: 1px solid var(--accent-color);
box-sizing: border-box;
color: inherit;
background-color: unset;
font-weight: medium;
letter-spacing: .04em;
}
#search-friend-bar:empty:before {
content: attr(placeholder);
color: gray;
}
</style>

View file

@ -1,50 +1,75 @@
<template>
<div v-if="props.type == 'normal'" :id="props.last ? 'last-message' : undefined" class="message normal-message">
<div v-if="props.type == 'normal' || props.replyMessage" ref="messageElement" @contextmenu="createContextMenu($event, menuItems)" :id="props.last ? 'last-message' : undefined"
class="message normal-message" :class="{ 'mentioned': (props.replyMessage || props.isMentioned) && props.message.user.uuid != props.me.uuid && props.replyMessage?.user.uuid == props.me.uuid }" :data-message-id="props.messageId"
:editing.sync="props.editing" :replying-to.sync="props.replyingTo">
<div v-if="props.replyMessage" class="message-reply-svg">
<svg
width="1.5em" height="1.5em"
viewBox="0 0 150 87.5" version="1.1" id="svg1"
style="overflow: visible;">
<defs id="defs1" />
<g id="layer1"
transform="translate(40,-35)">
<g id="g3"
transform="translate(-35,-20)">
<path
style="stroke:var(--reply-text-color);stroke-width:8;stroke-opacity:1"
d="m 120.02168,87.850978 100.76157,2.4e-5"
id="path3-5" />
<path
style="stroke:var(--reply-text-color);stroke-width:8;stroke-opacity:1"
d="M 69.899501,174.963 120.2803,87.700931"
id="path3-5-2" />
</g>
</g>
</svg>
</div>
<MessageReply v-if="props.replyMessage" :id="props.message.uuid"
:author="getDisplayName(props.replyMessage.user)"
:text="props.replyMessage?.message"
:reply-id="props.replyMessage.uuid" max-width="reply" />
<div class="left-column">
<img v-if="props.img" class="message-author-avatar" :src="props.img" :alt="username" />
<Icon v-else name="lucide:user" class="message-author-avatar" />
<Avatar :user="props.author" class="message-author-avatar"/>
</div>
<div class="message-data">
<div class="message-metadata">
<span class="message-author-username" tabindex="0">
{{ username }}
<span class="message-author-username" tabindex="0" :style="`color: ${props.authorColor}`">
{{ getDisplayName(props.author) }}
</span>
<span class="message-date" :title="date.toString()">
<span v-if="getDayDifference(date, currentDate) === 1">Yesterday at</span>
<span v-else-if="getDayDifference(date, currentDate) > 1 ">{{ date.toLocaleDateString(undefined) }},</span>
{{ date.toLocaleTimeString(undefined, { timeStyle: "short" }) }}
{{ date.toLocaleTimeString(undefined, { hour12: props.format == "12", timeStyle: "short" }) }}
</span>
</div>
<div class="message-text" v-html="sanitized" tabindex="0"></div>
<div class="message-text" v-html="sanitized" :hidden="hasEmbed" tabindex="0"></div>
</div>
<MessageMedia v-if="mediaLinks.length" :links="mediaLinks" />
</div>
<div v-else ref="messageElement" :id="props.last ? 'last-message' : undefined" class="message grouped-message" :class="{ 'message-margin-bottom': props.marginBottom }">
<div v-else ref="messageElement" @contextmenu="createContextMenu($event, menuItems)" :id="props.last ? 'last-message' : undefined"
class="message grouped-message" :class="{ 'message-margin-bottom': props.marginBottom, 'mentioned': props.replyMessage || props.isMentioned }"
:data-message-id="props.messageId" :editing.sync="props.editing" :replying-to.sync="props.replyingTo">
<div class="left-column">
<span :class="{ 'invisible': dateHidden }" class="message-date side-message-date" :title="date.toString()">
{{ date.toLocaleTimeString(undefined, { timeStyle: "short" }) }}
{{ date.toLocaleTimeString(undefined, { hour12: props.format == "12", timeStyle: "short" }) }}
</span>
</div>
<div class="message-data">
<div class="message-text" :class="$style['message-text']" v-html="sanitized" tabindex="0"></div>
<div class="message-text" :class="$style['message-text']" v-html="sanitized" :hidden="hasEmbed" tabindex="0"></div>
</div>
<MessageMedia v-if="mediaLinks.length" :links="mediaLinks" />
</div>
</template>
<script lang="ts" setup>
import DOMPurify from 'dompurify';
import { parse } from 'marked';
import type { MessageProps } from '~/types/props';
import MessageMedia from './MessageMedia.vue';
import MessageReply from './UserInterface/MessageReply.vue';
const props = defineProps<{
class?: string,
img?: string | null,
username: string,
text: string,
timestamp: number,
format: "12" | "24",
type: "normal" | "grouped",
marginBottom: boolean,
last: boolean
}>();
const props = defineProps<MessageProps>();
const messageElement = ref<HTMLDivElement>();
@ -53,16 +78,34 @@ const dateHidden = ref<boolean>(true);
const date = new Date(props.timestamp);
const currentDate: Date = new Date()
console.log("message:", props.text);
console.log("author:", props.username);
console.log("[MSG] message to render:", props.message);
console.log("author:", props.author);
console.log("[MSG] reply message:", props.replyMessage);
const linkRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)/g;
const linkMatches = props.message.message.matchAll(linkRegex).map(link => link[0]);
const mediaLinks: string[] = [];
console.log("link matches:", linkMatches);
const hasEmbed = ref(false);
const sanitized = ref<string>();
onMounted(async () => {
const parsed = await parse(props.text, { gfm: true });
sanitized.value = DOMPurify.sanitize(parsed, { ALLOWED_TAGS: ["strong", "em", "br", "blockquote", "code", "ul", "ol", "li", "a", "h1", "h2", "h3", "h4", "h5", "h6"] });
sanitized.value = DOMPurify.sanitize(parsed, {
ALLOWED_TAGS: [
"strong", "em", "br", "blockquote",
"code", "ul", "ol", "li", "a", "h1",
"h2", "h3", "h4", "h5", "h6"
],
ALLOW_DATA_ATTR: false,
ALLOW_SELF_CLOSE_IN_ATTR: false,
ALLOWED_ATTR: ["href"]
});
console.log("adding listeners")
await nextTick();
if (messageElement.value?.classList.contains("grouped-message")) {
messageElement.value?.addEventListener("mouseenter", (e: Event) => {
dateHidden.value = false;
});
@ -71,12 +114,43 @@ onMounted(async () => {
dateHidden.value = true;
});
console.log("added listeners");
}
for (const link of linkMatches) {
console.log("link:", link);
try {
const res = await $fetch.raw(link);
if (res.ok && res.headers.get("content-type")?.match(/^image\/(apng|gif|jpeg|png|webp)$/)) {
console.log("link is image");
mediaLinks.push(link);
}
if (mediaLinks.length) {
hasEmbed.value = true
setTimeout(() => {
scrollToBottom(document.getElementById("messages") as HTMLDivElement);
}, 500);
};
} catch (error) {
console.error(error);
}
}
console.log("media links:", mediaLinks);
});
//function toggleTooltip(e: Event) {
// showHover.value = !showHover.value;
//}
const menuItems = [
{ name: "Reply", callback: () => { if (messageElement.value) replyToMessage(messageElement.value, props) } }
]
console.log("me:", props.me);
if (props.author?.uuid == props.me.uuid) {
menuItems.push({ name: "Edit", callback: () => { if (messageElement.value) editMessage(messageElement.value, props) } });
}
function getDayDifference(date1: Date, date2: Date) {
const midnight1 = new Date(date1.getFullYear(), date1.getMonth(), date1.getDate());
const midnight2 = new Date(date2.getFullYear(), date2.getMonth(), date2.getDate());
@ -99,6 +173,12 @@ function getDayDifference(date1: Date, date2: Date) {
align-items: center;
column-gap: 1dvw;
width: 100%;
overflow-wrap: anywhere;
}
.message-reply-preview {
grid-row: 1;
grid-column: 2;
}
.message:hover {
@ -130,6 +210,8 @@ function getDayDifference(date1: Date, date2: Date) {
flex-direction: column;
height: fit-content;
width: 100%;
grid-row: 2;
grid-column: 2;
}
.message-author {
@ -139,7 +221,6 @@ function getDayDifference(date1: Date, date2: Date) {
.message-author-avatar {
width: 100%;
border-radius: 50%;
}
.left-column {
@ -148,6 +229,8 @@ function getDayDifference(date1: Date, date2: Date) {
justify-content: center;
text-align: center;
white-space: nowrap;
grid-row: 2;
grid-column: 1;
}
.author-username {
@ -174,16 +257,30 @@ function getDayDifference(date1: Date, date2: Date) {
width: 20px;
}
*/
.mentioned {
background-color: rgba(0, 255, 166, 0.123);
}
.mentioned:hover {
background-color: rgba(90, 255, 200, 0.233);
}
.message-reply-svg {
display: flex;
justify-content: center;
}
</style>
<style module>
.message-text ul, h1, h2, h3, h4, h5, h6 {
padding-top: 1dvh;
padding-bottom: 1dvh;
padding-top: .5em;
padding-bottom: .5em;
margin: 0;
}
.message-text ul {
padding-left: 2dvw;
padding-left: 1em;
}
</style>

View file

@ -1,11 +1,14 @@
<template>
<div id="message-area">
<div id="messages" ref="messagesElement">
<Message v-for="(message, i) of messages" :username="message.user.display_name ?? message.user.username"
<Message v-for="(message, i) of messages" :username="getDisplayName(message.user)"
:text="message.message" :timestamp="messageTimestamps[message.uuid]" :img="message.user.avatar"
format="12" :type="messagesType[message.uuid]"
:format="timeFormat" :type="messagesType[message.uuid]"
:margin-bottom="(messages[i + 1] && messagesType[messages[i + 1].uuid] == 'normal') ?? false"
:last="i == messages.length - 1" />
:last="i == messages.length - 1" :message-id="message.uuid" :author="message.user" :me="me"
:message="message" :is-reply="message.reply_to"
:author-color="`${generateIrcColor(message.user.uuid)}`"
:reply-message="message.reply_to ? getReplyMessage(message.reply_to) : undefined" />
</div>
<div id="message-box" class="rounded-corners">
<form id="message-form" @submit="sendMessage">
@ -37,14 +40,18 @@
</template>
<script lang="ts" setup>
import type { MessageResponse, ScrollPosition } from '~/types/interfaces';
import type { MessageResponse, ScrollPosition, UserResponse } from '~/types/interfaces';
import scrollToBottom from '~/utils/scrollToBottom';
import { generateIrcColor } from '#imports';
const props = defineProps<{ channelUrl: string, amount?: number, offset?: number }>();
const me = await fetchWithApi("/me") as UserResponse;
const messageTimestamps = ref<Record<string, number>>({});
const messagesType = ref<Record<string, "normal" | "grouped">>({});
const messageGroupingMaxDifference = useRuntimeConfig().public.messageGroupingMaxDifference
const timeFormat = getPreferredTimeFormat()
const messagesRes: MessageResponse[] | undefined = await fetchWithApi(
`${props.channelUrl}/messages`,
@ -114,6 +121,7 @@ if (messagesRes) {
messagesRes.reverse();
console.log("messages res:", messagesRes.map(msg => msg.message));
for (const message of messagesRes) {
console.log("[MSG] processing message:", message);
groupMessage(message);
}
}
@ -185,6 +193,7 @@ if (accessToken && apiBase) {
console.log("event data:", event.data);
console.log("message uuid:", event.data.uuid);
const parsedData = JSON.parse(event.data);
console.log("[MSG] parsed message:", parsedData);
console.log("parsed message type:", messagesType.value[parsedData.uuid]);
console.log("parsed message timestamp:", messageTimestamps.value[parsedData.uuid]);
@ -203,11 +212,18 @@ if (accessToken && apiBase) {
function sendMessage(e: Event) {
e.preventDefault();
if (messageInput.value && messageInput.value.trim() !== "") {
const message = {
const message: Record<string, string> = {
message: messageInput.value.trim().replace(/\n/g, "<br>") // trim, and replace \n with <br>
}
console.log("message:", message);
const messageReply = document.getElementById("message-reply") as HTMLDivElement;
console.log("[MSG] message reply:", messageReply);
if (messageReply && messageReply.dataset.messageId) {
console.log("[MSG] message is a reply");
message.reply_to = messageReply.dataset.messageId;
}
console.log("[MSG] sent message:", message);
ws.send(JSON.stringify(message));
// reset input field
@ -220,11 +236,25 @@ function sendMessage(e: Event) {
}
}
function getReplyMessage(id: string) {
console.log("[REPLYMSG] id:", id);
const messagesValues = Object.values(messages.value);
console.log("[REPLYMSG] messages values:", messagesValues);
for (const message of messagesValues) {
console.log("[REPLYMSG] message:", message);
console.log("[REPLYMSG] IDs match?", message.uuid == id);
if (message.uuid == id) return message;
}
}
const route = useRoute();
onMounted(async () => {
if (import.meta.server) return;
console.log("[MSG] messages keys:", Object.values(messages.value));
if (messagesElement.value) {
await nextTick();
await nextTick();
scrollToBottom(messagesElement.value);
let fetched = false;
const amount = messages.value.length;
@ -297,9 +327,11 @@ router.beforeEach((to, from, next) => {
padding-left: 1dvw;
padding-right: 1dvw;
overflow: hidden;
flex-grow: 1;
}
#message-box {
margin-top: auto; /* force it to the bottom of the screen */
margin-bottom: 2dvh;
margin-left: 1dvw;
margin-right: 1dvw;

View file

@ -0,0 +1,47 @@
<template>
<div class="media-container">
<NuxtImg v-for="link of props.links" class="media-item" :src="link" @click.prevent="createModal(link)" />
</div>
</template>
<script lang="ts" setup>
import { ModalBase } from '#components';
import { render } from 'vue';
const props = defineProps<{ links: string[] }>();
function createModal(link: string) {
const div = document.createElement("div");
const modal = h(ModalBase, {
obscure: true,
onClose: () => unrender(div),
onCancel: () => unrender(div),
},
[
h("img", {
src: link,
class: "default-contextmenu"
})
]);
document.body.appendChild(div);
render(modal, div);
}
</script>
<style scoped>
.media-container {
grid-column: 2;
grid-row: 3;
margin-left: .5dvw;
}
.media-item {
cursor: pointer;
max-width: 15dvw;
}
</style>

84
components/Modal/Base.vue Normal file
View file

@ -0,0 +1,84 @@
<template>
<dialog ref="dialog" class="modal" :class="props.obscure ? 'modal-obscure' : 'modal-regular'">
<span class="modal-exit-button-container" style="position: absolute; right: 2em; top: .2em; width: .5em; height: .5em;">
<Button text="X" variant="neutral" :callback="() => dialog?.remove()" />
</span>
<div class="modal-content">
<h1 class="modal-title">{{ title }}</h1>
<slot />
</div>
</dialog>
</template>
<script lang="ts" setup>
import type { ModalProps } from '~/types/interfaces';
import Button from '~/components/UserInterface/Button.vue';
const props = defineProps<ModalProps>();
const dialog = ref<HTMLDialogElement>();
console.log("props:", props);
onMounted(() => {
if (dialog.value) {
dialog.value.showModal();
if (props.onClose) {
dialog.value.addEventListener("close", props.onClose);
}
if (props.onCancel) {
dialog.value.addEventListener("cancel", props.onCancel);
}
}
});
</script>
<style>
.modal {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 1em;
opacity: 100%;
padding: 1%;
background-color: var(--sidebar-highlighted-background-color);
color: var(--text-color);
overflow: hidden;
}
.modal-regular::backdrop {
background-color: var(--chat-background-color);
opacity: 0%;
}
.modal-obscure::backdrop {
background-color: var(--chat-background-color);
opacity: 80%;
}
.modal-top-container {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
.modal-title {
font-size: 1.5rem;
padding: 0;
}
.modal-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1em;
margin: 1em;
width: 100%;
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<ModalBase v-bind="props" :title="props.title || 'Create an invite'">
<div v-if="invite" id="invite-body">
<div id="invite-label">{{ invite }}</div>
<div id="invite-buttons">
<Button text="Copy as link" variant="neutral" :callback="() => copyInvite('link')" />
<Button text="Copy as code" variant="neutral" :callback="() => copyInvite('code')" />
</div>
</div>
<div v-else>
<Button text="Generate Invite" variant="normal" :callback="generateInvite">Generate Invite</Button>
</div>
</ModalBase>
</template>
<script lang="ts" setup>
import type { InviteResponse, ModalProps } from '~/types/interfaces';
import Button from '~/components/UserInterface/Button.vue';
const props = defineProps<ModalProps & { guildId: string }>();
const invite = ref<string>();
async function generateInvite(): Promise<void> {
const chars = "ABCDEFGHIJKLMNOQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
let randCode = "";
for (let i = 0; i < 6; i++) {
randCode += chars[Math.floor(Math.random() * chars.length)];
}
const createdInvite: InviteResponse | undefined = await fetchWithApi(
`/guilds/${props.guildId}/invites`,
{ method: "POST", body: { custom_id: randCode } }
);
invite.value = createdInvite?.id;
return;
}
function copyInvite(type: "link" | "code") {
if (!invite.value) return;
if (type == "link") {
const inviteUrl = URL.parse(`invite/${invite.value}`, `${window.location.protocol}//${window.location.host}`);
if (inviteUrl) {
navigator.clipboard.writeText(inviteUrl.href);
}
} else {
navigator.clipboard.writeText(invite.value);
}
}
</script>
<style scoped>
#invite-body, #invite-buttons {
display: flex;
gap: 1em;
}
#invite-body {
flex-direction: column;
}
#invite-label {
text-align: center;
color: aquamarine;
}
</style>

View file

@ -10,6 +10,7 @@
<script lang="ts" setup>
import Cropper from 'cropperjs';
import Button from '../UserInterface/Button.vue';
const props = defineProps({
imageSrc: String,

View file

@ -17,19 +17,28 @@
</div>
</div>
<p class="subtitle">ICONS</p>
<div class="themes">
<!-- <p class="subtitle">Icons</p>
<div class="icons">
</div> -->
<p class="subtitle">TIME FORMAT</p>
<div class="icons">
<RadioButtons :button-count="3" :text-strings="timeFormatTextStrings"
:default-button-index="settingsLoad().timeFormat?.index ?? 0" :callback="onTimeFormatClicked"></RadioButtons>
</div>
</div>
</template>
<script lang="ts" setup>
import RadioButtons from '~/components/UserInterface/RadioButtons.vue';
import type { TimeFormat } from '~/types/settings';
import loadPreferredTheme from '~/utils/loadPreferredTheme';
import settingSave from '~/utils/settingSave';
const runtimeConfig = useRuntimeConfig()
const defaultThemes = runtimeConfig.public.defaultThemes
const baseURL = runtimeConfig.app.baseURL;
let themeLinkElement: HTMLLinkElement | null = null;
const timeFormatTextStrings = ["Auto", "12-Hour", "24-Hour"]
const themes: Array<Theme> = []
@ -42,20 +51,8 @@ interface Theme {
}
function changeTheme(id: string, url: string) {
if (themeLinkElement && themeLinkElement.getAttribute('href') === `${baseURL}themes/${url}`) {
return;
}
localStorage.setItem("selectedTheme", id);
// if the theme didn't originally load for some reason, create it
if (!themeLinkElement) {
themeLinkElement = document.createElement('link');
themeLinkElement.rel = 'stylesheet';
document.head.appendChild(themeLinkElement);
}
themeLinkElement.href = `${baseURL}themes/${url}`;
settingSave("selectedThemeId", id)
loadPreferredTheme()
}
async function fetchThemes() {
@ -68,6 +65,21 @@ async function fetchThemes() {
}
await fetchThemes()
async function onTimeFormatClicked(index: number) {
let format: "auto" | "12" | "24" = "auto"
if (index == 0) {
format = "auto"
} else if (index == 1) {
format = "12"
} else if (index == 2) {
format = "24"
}
const timeFormat: TimeFormat = {index, format}
settingSave("timeFormat", timeFormat)
}
</script>
<style scoped>

View file

@ -0,0 +1,11 @@
import Appearance from './Appearance.vue';
import Notifications from './Notifications.vue';
import Keybinds from './Keybinds.vue';
import Language from './Language.vue';
export {
Appearance,
Notifications,
Keybinds,
Language,
}

View file

@ -17,7 +17,7 @@
</template>
<script lang="ts" setup>
import Button from '~/components/Button.vue';
import Button from '~/components/UserInterface/Button.vue';
import type { UserResponse } from '~/types/interfaces';
const { fetchUser } = useAuth();
@ -43,6 +43,14 @@ async function changeEmail() {
body: formData
})
const apiBase = useCookie("api_base").value;
if (apiBase) {
const stats = await useApi().fetchInstanceStats(apiBase);
if (stats.email_verification_required) {
return window.location.reload();
}
}
alert('success!!')
} catch (error: any) {
if (error?.response?.status !== 200) {

View file

@ -5,7 +5,6 @@
</template>
<script lang="ts" setup>
import Button from '~/components/Button.vue';
</script>
<style scoped>

View file

@ -33,12 +33,16 @@
</template>
<script lang="ts" setup>
import CropPopup from '~/components/Popups/CropPopup.vue';
import UserPopup from '~/components/User/UserPopup.vue';
import Button from '~/components/UserInterface/Button.vue';
import type { UserResponse } from '~/types/interfaces';
let newPfpFile: File;
const isCropPopupVisible = ref(false);
const cropImageSrc = ref("")
;
const { fetchUser } = useAuth();
const user: UserResponse | undefined = await fetchUser()

View file

@ -0,0 +1,13 @@
import Profile from './Profile.vue';
import Account from './Account.vue';
import Privacy from './Privacy.vue';
import Devices from './Devices.vue';
import Connections from './Connections.vue';
export {
Profile,
Account,
Privacy,
Devices,
Connections,
}

View file

@ -0,0 +1,48 @@
<template>
<NuxtLink class="user-item" :href="`/me/${user.uuid}`" tabindex="0">
<Avatar :user="props.user" class="user-avatar"/>
<span class="user-display-name">{{ getDisplayName(props.user) }}</span>
</NuxtLink>
</template>
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const props = defineProps<{
user: UserResponse
}>();
</script>
<style>
.user-item {
display: flex;
align-items: center;
text-align: left;
margin-top: .5em;
margin-bottom: .5em;
gap: .5em;
text-decoration: none;
color: inherit;
}
.user-item:hover {
background-color: #00000020
}
.user-avatar {
min-width: 2.3em;
max-width: 2.3em;
min-width: 2.3em;
max-height: 2.3em;
}
.user-display-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -1,12 +1,11 @@
<template>
<div id="profile-popup">
<img v-if="props.user.avatar" id="avatar" :src="props.user.avatar" alt="profile avatar">
<Icon v-else id="avatar" name="lucide:user" />
<Avatar :user="props.user" id="avatar"/>
<div id="cover-color"></div>
<div id="main-body">
<p id="display-name">
<strong>{{ props.user.display_name }}</strong>
<strong>{{ getDisplayName(props.user) }}</strong>
</p>
<p id="username-and-pronouns">
{{ props.user.username }}
@ -22,8 +21,6 @@
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const { fetchMembers } = useApi();
const props = defineProps<{
user: UserResponse
}>();

View file

@ -1,20 +0,0 @@
<template>
<div id="user-panel">
HELLO!!
</div>
</template>
<script lang="ts" setup>
import type { UserResponse } from '~/types/interfaces';
const props = defineProps<{
user: UserResponse,
}>();
</script>
<style scoped>
#user-panel {
width: 100%;
}
</style>

View file

@ -1,14 +1,14 @@
<template>
<div @click="props.callback()" class="button" :class="props.variant + '-button'">
<button @click="props.callback ? props.callback() : null" class="button" :class="props.variant + '-button'">
{{ props.text }}
</div>
</button>
</template>
<script lang="ts" setup>
const props = defineProps<{
text: string,
callback: CallableFunction,
callback?: CallableFunction,
variant?: "normal" | "scary" | "neutral",
}>();
@ -21,13 +21,15 @@ const props = defineProps<{
background-color: var(--primary-color);
color: var(--text-color);
padding: 0.7dvh 1.2dvw;
padding: 0.4em 0.75em;
font-size: 1.1em;
transition: background-color 0.2s;
border-radius: 0.7rem;
text-decoration: none;
display: inline-block;
border: none;
}
.button:hover {

View file

@ -0,0 +1,44 @@
<template>
<div v-for="item of props.menuItems" class="context-menu-item" @click="runCallback(item)">
{{ item.name }}
</div>
</template>
<script lang="ts" setup>
import type { ContextMenuItem } from '~/types/interfaces';
const props = defineProps<{ menuItems: ContextMenuItem[], pointerX: number, pointerY: number }>();
onMounted(() => {
const contextMenu = document.getElementById("context-menu");
if (contextMenu) {
contextMenu.style.left = props.pointerX.toString() + "px";
contextMenu.style.top = props.pointerY.toString() + "px";
}
});
function runCallback(item: ContextMenuItem) {
removeContextMenu();
item.callback();
}
</script>
<style>
#context-menu {
position: absolute;
display: flex;
flex-direction: column;
width: 10dvw;
border: .15rem solid cyan;
background-color: var(--background-color);
text-align: center;
}
.context-menu-item:hover {
background-color: rgb(50, 50, 50);
}
</style>

View file

@ -0,0 +1,92 @@
<template>
<div :id="props.maxWidth == 'full' ? 'message-reply' : undefined" :class="{ 'message-reply-preview' : props.maxWidth == 'reply' }"
:data-message-id="props.id" @click="scrollToReply">
<span id="reply-text">Replying to <span id="reply-author-field">{{ props.author }}:</span> <span v-html="sanitized"></span></span>
<!-- <span id="message-reply-cancel"><Icon name="lucide:x" /></span> -->
</div>
</template>
<script lang="ts" setup>
import DOMPurify from "dompurify";
import { parse } from "marked";
const props = defineProps<{ author: string, text: string, id: string, replyId: string, maxWidth: "full" | "reply" }>();
const existingReply = document.getElementById("message-reply");
if (existingReply) {
existingReply.remove();
}
console.log("text:", props.text);
const sanitized = ref<string>();
onMounted(async () => {
const parsed = await parse(props.text.trim().replaceAll("<br>", " "), { gfm: true });
sanitized.value = DOMPurify.sanitize(parsed, {
ALLOWED_TAGS: [],
ALLOW_DATA_ATTR: false,
ALLOW_SELF_CLOSE_IN_ATTR: false,
ALLOWED_ATTR: [],
KEEP_CONTENT: true
});
console.log("sanitized:", sanitized.value);
const messageBoxInput = document.getElementById("message-textbox-input") as HTMLDivElement;
if (messageBoxInput) {
messageBoxInput.focus();
}
});
function scrollToReply(e: MouseEvent) {
e.preventDefault();
console.log("clicked on reply box");
const reply = document.querySelector(`.message[data-message-id="${props.replyId}"]`);
if (reply) {
console.log("reply:", reply);
console.log("scrolling into view");
reply.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
</script>
<style scoped>
#message-reply, .message-reply-preview {
display: flex;
text-align: left;
margin-bottom: .5rem;
cursor: pointer;
overflow: hidden;
}
#message-reply {
width: 100%;
}
.message-reply-preview {
margin-left: .5dvw;
}
#reply-text {
color: var(--reply-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0;
margin-top: .2rem;
border-bottom: 1px solid var(--padding-color);
}
#reply-author-field {
color: var(--text-color);
}
</style>

View file

@ -0,0 +1,111 @@
<template>
<div class="radio-buttons-container" ref="radioButtonsContainer">
<div v-for="index in indices" :key="index" class="radio-button" @click="onClick(index)">
<span class="radio-button-radio"></span>
<span class="radio-button-text">{{ textStrings[index] }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
const radioButtonsContainer = ref<HTMLDivElement>()
const props = defineProps<{
textStrings: string[],
buttonCount: number,
defaultButtonIndex: number,
callback: CallableFunction,
}>();
// makes an array from 0 to buttonCount - 1
const indices = Array.from({ length: props.buttonCount }, (_, i) => i)
// select default selected button
onMounted(async () => {
await nextTick()
if (props.defaultButtonIndex != undefined && radioButtonsContainer.value) {
const children = radioButtonsContainer.value.children
const defaultButton = children.item(props.defaultButtonIndex)
defaultButton?.classList.add("selected-radio-button")
defaultButton?.children.item(0)?.classList.add("selected-radio-button-radio")
}
})
function onClick(clickedIndex: number) {
// remove selected-radio-button class from all buttons except the clicked one
if (radioButtonsContainer.value) {
const children = radioButtonsContainer.value.children
for (let i = 0; i < children.length; i++) {
children.item(i)?.classList.remove("selected-radio-button")
children.item(i)?.children.item(0)?.classList.remove("selected-radio-button-radio")
}
children.item(clickedIndex)?.classList.add("selected-radio-button")
children.item(clickedIndex)?.children.item(0)?.classList.add("selected-radio-button-radio")
}
props.callback(clickedIndex)
}
</script>
<style scoped>
.radio-buttons-container {
display: flex;
flex-direction: column;
}
.radio-button {
cursor: pointer;
display: flex;
align-items: center;
border-radius: .66em;
background-color: unset;
color: var(--text-color);
padding: 0.4em 0.75em;
margin: 0.4em 0em;
font-size: 1.1em;
transition: background-color 0.2s;
}
.radio-button:hover {
background-color: var(--secondary-highlighted-color);
}
.selected-radio-button {
background-color: var(--accent-color);
}
.selected-radio-button:hover {
background-color: var(--accent-highlighted-color);
}
.radio-button-radio, .selected-radio-button-radio {
position: relative;
display: inline-block;
border-radius: 1em;
}
.radio-button-radio {
height: 1em;
width: 1em;
border: .15em solid var(--primary-color);
}
.selected-radio-button-radio {
height: 1em;
width: 1em;
border: 0.15em solid var(--primary-color);
background-color: var(--primary-highlighted-color);
}
.radio-button-text {
margin-left: .5em;
}
</style>

View file

@ -0,0 +1,140 @@
<template>
<div ref="resizableSidebar" class="resizable-sidebar"
:style="{
'width': storedWidth ? `${storedWidth}px` : props.width,
'min-width': props.minWidth,
'max-width': props.maxWidth,
'border': props.borderSides == 'all' ? borderStyling : undefined,
'border-top': props.borderSides?.includes('top') ? borderStyling : undefined,
'border-bottom': props.borderSides?.includes('bottom') ? borderStyling : undefined,
}">
<div v-if="props.borderSides != 'right'" class="width-resizer-bar">
<div ref="widthResizer" class="width-resizer"></div>
</div>
<div class="sidebar-content">
<slot />
</div>
<div v-if="props.borderSides == 'right'" class="width-resizer-bar">
<div ref="widthResizer" class="width-resizer"></div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ContextMenuItem } from '~/types/interfaces';
const props = defineProps<{ width?: string, minWidth: string, maxWidth: string, borderSides: "all" | "top" | "right" | "bottom" | "left" | ("top" | "right" | "bottom" | "left")[], localStorageName?: string }>();
const borderStyling = ".1rem solid var(--padding-color)";
const resizableSidebar = ref<HTMLDivElement>();
const widthResizer = ref<HTMLDivElement>();
const storedWidth = ref<number>();
const menuItems: ContextMenuItem[] = [
{ name: "Reset", callback: () => { resizableSidebar.value!.style.width = props.width ?? props.minWidth } }
]
onMounted(() => {
loadStoredWidth();
if (resizableSidebar.value && widthResizer.value) {
widthResizer.value.addEventListener("pointerdown", (e) => {
e.preventDefault();
if (e.button == 2) {
createContextMenu(e, menuItems);
return
};
document.body.style.cursor = "ew-resize";
function handleMove(pointer: PointerEvent) {
if (resizableSidebar.value) {
console.log("moving");
console.log("pointer:", pointer);
console.log("width:", resizableSidebar.value.style.width);
let delta = 0;
if (props.borderSides == 'right') {
delta = resizableSidebar.value.getBoundingClientRect().left;
console.log("delta:", delta);
resizableSidebar.value.style.width = `${pointer.clientX - delta}px`;
} else {
delta = resizableSidebar.value.getBoundingClientRect().right;
console.log("delta:", delta);
resizableSidebar.value.style.width = `${delta - pointer.clientX}px`;
}
}
}
document.addEventListener("pointermove", handleMove);
document.addEventListener("pointerup", () => {
console.log("pointer up");
document.removeEventListener("pointermove", handleMove);
console.log("removed pointermove event listener");
document.body.style.cursor = "";
if (resizableSidebar.value && props.localStorageName) {
localStorage.setItem(props.localStorageName, resizableSidebar.value.style.width);
}
}, { once: true });
});
}
});
onActivated(() => {
console.log("[res] activated");
loadStoredWidth();
});
function loadStoredWidth() {
if (props.localStorageName) {
const storedWidthValue = localStorage.getItem(props.localStorageName);
if (storedWidthValue) {
storedWidth.value = parseInt(storedWidthValue) || undefined;
console.log("[res] loaded stored width");
}
}
}
</script>
<style>
.resizable-sidebar > * {
box-sizing: border-box;
}
.resizable-sidebar {
display: flex;
background: var(--optional-channel-list-background);
background-color: var(--sidebar-background-color);
height: 100%;
flex: 0 0 auto;
}
.width-resizer {
width: .5rem;
cursor: col-resize;
position: absolute;
height: 100%;
}
.width-resizer-bar {
display: flex;
justify-content: center;
position: relative;
height: 100%;
width: 1px;
background-color: var(--padding-color);
}
.sidebar-content {
width: 100%;
padding-left: .25em;
padding-right: .25em;
}
.sidebar-content > :first-child {
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
}
</style>

View file

@ -0,0 +1,12 @@
<template>
<span class="spacer"></span>
</template>
<style scoped>
.spacer {
height: 0.2dvh;
display: block;
margin: 0.8dvh 0.2dvw;
background-color: var(--padding-color);
}
</style>

View file

@ -1,24 +1,36 @@
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse } from "~/types/interfaces";
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse, StatsResponse, UserResponse } from "~/types/interfaces";
function ensureIsArray(list: any) {
if (Array.isArray(list)) {
return list
} else {
return []
}
}
export const useApi = () => {
async function fetchGuilds(): Promise<GuildResponse[] | undefined> {
return await fetchWithApi(`/guilds`);
async function fetchGuilds(): Promise<GuildResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds`));
}
async function fetchGuild(guildId: string): Promise<GuildResponse | undefined> {
return await fetchWithApi(`/guilds/${guildId}`);
}
async function fetchChannels(guildId: string): Promise<ChannelResponse[] | undefined> {
return await fetchWithApi(`/guilds/${guildId}/channels`);
async function fetchMyGuilds(): Promise<GuildResponse[]> {
return ensureIsArray(await fetchWithApi(`/me/guilds`));
}
async function fetchChannels(guildId: string): Promise<ChannelResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds/${guildId}/channels`));
}
async function fetchChannel(channelId: string): Promise<ChannelResponse | undefined> {
return await fetchWithApi(`/channels/${channelId}`)
}
async function fetchMembers(guildId: string): Promise<GuildMemberResponse[] | undefined> {
return await fetchWithApi(`/guilds/${guildId}/members`);
async function fetchMembers(guildId: string): Promise<GuildMemberResponse[]> {
return ensureIsArray(await fetchWithApi(`/guilds/${guildId}/members`));
}
async function fetchMember(guildId: string, memberId: string): Promise<GuildMemberResponse | undefined> {
@ -33,6 +45,18 @@ export const useApi = () => {
return await fetchWithApi(`/users/${userId}`);
}
async function fetchFriends(): Promise<UserResponse[]> {
return ensureIsArray(await fetchWithApi('/me/friends'));
}
async function addFriend(username: string): Promise<void> {
return await fetchWithApi('/me/friends', { method: "POST", body: { username } });
}
async function removeFriend(userId: string): Promise<void> {
return await fetchWithApi(`/me/friends/${userId}`, { method: "DELETE" });
}
async function fetchMessages(channelId: string, options?: { amount?: number, offset?: number }): Promise<MessageResponse[] | undefined> {
return await fetchWithApi(`/channels/${channelId}/messages`, { query: { amount: options?.amount ?? 100, offset: options?.offset ?? 0 } });
}
@ -41,16 +65,61 @@ export const useApi = () => {
return await fetchWithApi(`/channels/${channelId}/messages/${messageId}`);
}
async function createGuild(name: string): Promise<GuildResponse | undefined> {
return await fetchWithApi(`/guilds`, { method: "POST", body: { name } });
}
async function joinGuild(invite: string): Promise<GuildResponse> {
return await fetchWithApi(`/invites/${invite}`, { method: "POST" }) as GuildResponse;
}
async function createChannel(guildId: string, name: string, description?: string): Promise<void> {
return await fetchWithApi(`/guilds/${guildId}/channels`, { method: "POST", body: { name, description } });
}
async function fetchInstanceStats(apiBase: string): Promise<StatsResponse> {
return await $fetch(`${apiBase}/stats`, { method: "GET" });
}
async function sendVerificationEmail(): Promise<void> {
const email = useAuth().user.value?.email;
await fetchWithApi("/auth/verify-email", { method: "POST", body: { email } });
}
async function sendPasswordResetEmail(identifier: string): Promise<void> {
await fetchWithApi("/auth/reset-password", { method: "GET", query: { identifier } });
}
async function resetPassword(password: string, token: string) {
await fetchWithApi("/auth/reset-password", { method: "POST", body: { password, token } });
}
async function fetchInvite(id: string): Promise<GuildResponse | undefined> {
return await fetchWithApi(`/invites/${id}`);
}
return {
fetchGuilds,
fetchGuild,
fetchMyGuilds,
fetchChannels,
fetchChannel,
fetchMembers,
fetchMember,
fetchUsers,
fetchUser,
fetchFriends,
addFriend,
removeFriend,
fetchMessages,
fetchMessage
fetchMessage,
createGuild,
joinGuild,
createChannel,
fetchInstanceStats,
sendVerificationEmail,
sendPasswordResetEmail,
resetPassword,
fetchInvite
}
}

View file

@ -7,6 +7,7 @@ export const useAuth = () => {
async function clearAuth() {
accessToken.value = null;
user.value = null;
await navigateTo("/login");
}
async function register(username: string, email: string, password: string) {
@ -40,7 +41,7 @@ export const useAuth = () => {
async function logout() {
console.log("access:", accessToken.value);
await fetchWithApi("/auth/logout", { method: "GET", credentials: "include" });
await fetchWithApi("/auth/logout", { method: "DELETE", credentials: "include" });
clearAuth();
return await navigateTo("/login");
@ -105,7 +106,7 @@ export const useAuth = () => {
}
return {
accessToken,
clearAuth,
register,
login,
logout,

View file

@ -18,32 +18,10 @@
</div>
<div v-else id="auth-form-container">
<slot />
<div v-if="!['/recover', '/reset-password'].includes(route.path)">Forgot password? Recover <NuxtLink href="/recover">here</NuxtLink>!</div>
</div>
<div v-if="instanceUrl">
Instance URL is set to {{ instanceUrl }}
</div>
<div v-if="auth.accessToken.value">
You're logged in!
<form @submit="logout">
<div>
<label for="logout-password">Password</label>
<br>
<input type="password" name="logout-password" id="logout-password" v-model="form.password"
required>
</div>
<div>
<button type="submit">Log out</button>
</div>
</form>
<div>
<button @click="refresh">Refresh</button>
</div>
<div>
<button @click="showUser">Show user</button>
</div>
<div>
<button @click="getUser">Get me</button>
</div>
Instance URL is set to <span style="color: var(--primary-color);">{{ instanceUrl }}</span>
</div>
</div>
</div>
@ -51,7 +29,6 @@
<script lang="ts" setup>
import { FetchError } from 'ofetch';
import type { StatsResponse } from '~/types/interfaces';
const instanceUrl = ref<string | null | undefined>(null);
const instanceUrlInput = ref<string>();
@ -60,12 +37,14 @@ const apiVersion = useRuntimeConfig().public.apiVersion;
const apiBase = useCookie("api_base");
const registrationEnabled = useState("registrationEnabled", () => true);
const auth = useAuth();
const route = useRoute();
const query = route.query as Record<string, string>;
const searchParams = new URLSearchParams(query);
searchParams.delete("token");
onMounted(async () => {
const cookie = useCookie("instance_url").value;
instanceUrl.value = cookie;
console.log(cookie);
instanceUrl.value = useCookie("instance_url").value;
console.log("set instance url to:", instanceUrl.value);
});
@ -73,8 +52,8 @@ async function selectInstance(e: Event) {
e.preventDefault();
console.log("trying input instance");
if (instanceUrlInput.value) {
console.log("input has value");
const gorbTxtUrl = new URL(`/.well-known/gorb.txt`, instanceUrlInput.value);
console.log("input has value");
try {
console.log("trying to get gorb.txt:", gorbTxtUrl);
const res = await $fetch.raw(gorbTxtUrl.href, { responseType: "text" });
@ -87,10 +66,10 @@ async function selectInstance(e: Event) {
instanceUrl.value = origin;
useCookie("instance_url").value = origin;
console.log("set instance url to:", origin);
const { status, data, error } = await useFetch<StatsResponse>(`${apiBase.value}/stats`);
if (status.value == "success" && data.value) {
registrationEnabled.value = data.value.registration_enabled;
console.log("set registration enabled value to:", data.value.registration_enabled);
const stats = await useApi().fetchInstanceStats(apiBase.value);
if (stats) {
registrationEnabled.value = stats.registration_enabled;
console.log("set registration enabled value to:", stats.registration_enabled);
}
return;
}
@ -114,30 +93,6 @@ async function selectInstance(e: Event) {
const form = reactive({
password: ""
});
async function logout(e: Event) {
e.preventDefault();
await auth.logout(form.password);
console.log("logout");
}
async function refresh(e: Event) {
e.preventDefault();
await auth.refresh();
console.log("refreshed");
}
async function getUser(e: Event) {
e.preventDefault();
await auth.getUser();
console.log("user:", auth.user.value);
}
async function showUser(e: Event) {
e.preventDefault();
console.log("user:", auth.user.value);
}
</script>
<style>
@ -148,18 +103,23 @@ async function showUser(e: Event) {
align-items: center;
}
#auth-form-container,
#auth-form-container form {
#auth-form-container {
display: flex;
width: 50dvw;
width: 20dvw;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1em;
margin-bottom: 2dvh;
}
#auth-form-container form {
display: flex;
flex-direction: column;
align-items: center;
text-align: left;
margin-top: 10dvh;
gap: 1em;
}
#instance-error-container {

View file

@ -3,42 +3,182 @@
<div :class="{ hidden: loading, visible: !loading }" id="client-root">
<div id="homebar">
<div class="homebar-item">
main bar
<marquee>
gorb!!!!!
</marquee>
</div>
</div>
<div id="page-content">
<div id="left-column">
<NuxtLink id="home-button" href="/">
<div class="left-column-segment">
<NuxtLink id="home-button" href="/me">
<img class="sidebar-icon" src="/public/icon.svg"/>
</NuxtLink>
<div id="servers-list">
</div>
<VerticalSpacer />
<div class="left-column-segment" id="left-column-middle">
<NuxtLink v-for="guild of guilds" :href="`/servers/${guild.uuid}`">
<img v-if="guild.icon" class="sidebar-icon" :src="guild.icon" :alt="guild.name"/>
<Icon v-else name="lucide:server" class="sidebar-icon white" :alt="guild.name" />
<NuxtImg v-if="guild.icon"
class="sidebar-icon guild-icon"
:alt="guild.name"
:src="guild.icon" />
<NuxtImg v-else-if="!blockedCanvas"
class="sidebar-icon guild-icon"
:alt="guild.name"
:src="generateDefaultIcon(guild.name, guild.uuid)" />
<Icon v-else name="lucide:server"
:style="`color: ${generateIrcColor(guild.uuid, 50)}`"
class="sidebar-icon guild-icon"
:alt="guild.name" />
</NuxtLink>
</div>
<NuxtLink id="settings-menu" href="/settings">
<Icon name="lucide:settings" class="sidebar-icon white" alt="Settings menu" />
<VerticalSpacer />
<div class="left-column-segment">
<div ref="createButtonContainer">
<button id="create-button" class="sidebar-bottom-buttons" @click.prevent="createDropdown">
<Icon id="create-icon" name="lucide:square-plus" alt="Create or join guild"/>
</button>
</div>
<NuxtLink id="settings-menu" class="sidebar-bottom-buttons" href="/settings">
<Icon name="lucide:settings" alt="Settings menu" />
</NuxtLink>
</div>
</div>
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
import { ModalBase } from '#components';
import { render } from 'vue';
import GuildDropdown from '~/components/Guild/GuildDropdown.vue';
import Button from '~/components/UserInterface/Button.vue';
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
import type { GuildResponse } from '~/types/interfaces';
const loading = useState("loading", () => false);
const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
const createButtonContainer = ref<HTMLButtonElement>();
const api = useApi();
const blockedCanvas = isCanvasBlocked()
const options = [
{ name: "Join", value: "join", callback: async () => {
console.log("join guild!");
const div = document.createElement("div");
const guildJoinModal = h(ModalBase, {
title: "Join Guild",
id: "guild-join-modal",
onClose: () => {
unrender(div);
},
onCancel: () => {
unrender(div);
},
style: "height: 20dvh; width: 15dvw"
},
[
h("input", {
id: "guild-invite-input",
type: "text",
placeholder: "oyqICZ",
}),
h(Button, {
text: "Join",
variant: "normal",
callback: async () => {
const input = document.getElementById("guild-invite-input") as HTMLInputElement;
const invite = input.value;
if (invite.length == 6) {
try {
const joinedGuild = await api.joinGuild(invite);
guilds.push(joinedGuild);
return await navigateTo(`/servers/${joinedGuild.uuid}`);
} catch (error) {
alert(`Couldn't use invite: ${error}`);
}
}
}
})
]);
document.body.appendChild(div);
render(guildJoinModal, div);
}
},
{ name: "Create", value: "create", callback: async () => {
console.log("create guild");
const user = await useAuth().getUser();
const div = document.createElement("div");
const guildCreateModal = h(ModalBase, {
title: "Create a Guild",
id: "guild-join-modal",
onClose: () => {
unrender(div);
},
onCancel: () => {
unrender(div);
},
style: "height: 20dvh; width: 15dvw;"
},
[
h("input", {
id: "guild-name-input",
type: "text",
placeholder: `${getDisplayName(user!)}'s Awesome Bouncy Castle'`,
style: "width: 100%"
}),
h(Button, {
text: "Create!",
variant: "normal",
callback: async () => {
const input = document.getElementById("guild-name-input") as HTMLInputElement;
const name = input.value;
try {
const guild = (await api.createGuild(name)) as GuildResponse;
await api.createChannel(guild.uuid, "general");
} catch (error) {
alert(`Couldn't create guild: ${error}`);
}
}
})
]);
document.body.appendChild(div);
render(guildCreateModal, div);
}
}
];
const guilds = await api.fetchMyGuilds();
function createDropdown() {
const dropdown = h(GuildDropdown, { options });
const div = document.createElement("div");
div.classList.add("dropdown", "destroy-on-click");
if (createButtonContainer.value) {
createButtonContainer.value.appendChild(div);
} else {
document.body.appendChild(div);
}
render(dropdown, div);
div.addEventListener("keyup", (e) => {
if (e.key == "Escape") {
unrender(div);
}
});
div.focus();
}
</script>
<style>
#client-root {
/* border: 1px solid white; */
height: 100dvh;
display: grid;
grid-template-columns: 1fr 4fr 18fr 4fr;
grid-template-rows: 4dvh auto;
width: 100dvw;
display: flex;
flex-direction: column;
text-align: center;
}
@ -48,81 +188,88 @@ const guilds: GuildResponse[] | undefined = await fetchWithApi("/me/guilds");
.visible {
opacity: 100%;
transition-duration: 500ms;
transition: opacity 500ms;
}
#homebar {
grid-row: 1;
grid-column: 1 / -1;
min-height: 4dvh;
display: flex;
justify-content: space-evenly;
align-items: center;
background: var(--optional-topbar-background);
background-color: var(--topbar-background-color);
border-bottom: 1px solid var(--padding-color);
padding-left: 5dvw;
padding-right: 5dvw;
}
#client-root>div:nth-child(-n+4) {
border-bottom: 1px solid var(--padding-color);
.homebar-item {
width: 100dvw;
}
#__nuxt {
#page-content {
display: flex;
flex-flow: column;
}
.grid-column {
padding-top: 1dvh;
}
#home {
padding-left: .5dvw;
padding-right: .5dvw;
}
.sidebar-icon {
width: 3rem;
height: 3rem;
}
#current-info {
grid-column: 2;
grid-row: 1;
flex-direction: row;
flex-grow: 1;
overflow: auto;
}
#left-column {
display: flex;
flex-direction: column;
gap: 2dvh;
padding-left: .5dvw;
padding-right: .5dvw;
border-right: 1px solid var(--padding-color);
padding-left: var(--sidebar-margin);
padding-right: var(--sidebar-margin);
padding-top: .5em;
background: var(--optional-sidebar-background);
background-color: var(--sidebar-background-color);
padding-top: 1.5dvh;
border-right: 1px solid var(--padding-color);
}
#middle-left-column {
padding-left: 1dvw;
padding-right: 1dvw;
border-right: 1px solid var(--padding-color);
.left-column-segment {
display: flex;
flex-direction: column;
scrollbar-width: none;
}
.left-column-segment::-webkit-scrollbar {
display: none;
}
#left-column-middle {
overflow-y: scroll;
flex-grow: 1;
gap: var(--sidebar-icon-gap);
}
#home-button {
border-bottom: 1px solid var(--padding-color);
padding-bottom: 1dvh;
height: var(--sidebar-icon-width);
}
#settings-menu {
position: absolute;
bottom: .25dvh
.guild-icon {
border-radius: var(--guild-icon-radius);
}
#servers-list {
display: flex;
flex-direction: column;
gap: 1dvh;
.sidebar-icon {
width: var(--sidebar-icon-width);
height: var(--sidebar-icon-width);
}
.sidebar-bottom-buttons {
color: var(--primary-color);
background-color: transparent;
border: none;
cursor: pointer;
font-size: 2.4rem;
padding: 0;
display: inline-block;
}
.sidebar-bottom-buttons:hover {
color: var(--primary-highlighted-color);
}
</style>

View file

@ -2,7 +2,21 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
console.log("to.fullPath:", to.fullPath);
const loading = useState("loading");
const accessToken = useCookie("access_token").value;
if (["/login", "/register"].includes(to.path)) {
const apiBase = useCookie("api_base").value;
const { fetchInstanceStats } = useApi();
console.log("[AUTH] instance url:", apiBase);
if (apiBase && !Object.keys(to.query).includes("special") && to.path != "/verify-email") {
const user = await useAuth().getUser();
const stats = await fetchInstanceStats(apiBase);
console.log("[AUTH] stats:", stats);
console.log("[AUTH] email verification check:", user?.email && !user.email_verified && stats.email_verification_required);
if (user?.email && !user.email_verified && stats.email_verification_required) {
return await navigateTo("/register?special=verify_email");
}
}
if (["/login", "/register", "/recover", "/reset-password"].includes(to.path) && !Object.keys(to.query).includes("special")) {
console.log("path is login or register");
const apiBase = useCookie("api_base");
console.log("apiBase gotten:", apiBase.value);
@ -19,6 +33,14 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (parsed.ApiBaseUrl) {
apiBase.value = `${parsed.ApiBaseUrl}/v${apiVersion}`;
console.log("set apiBase to:", parsed.ApiBaseUrl);
console.log("hHEYOO");
const instanceUrl = useCookie("instance_url");
console.log("hHEYOO 2");
console.log("instance url:", instanceUrl.value);
if (!instanceUrl.value) {
instanceUrl.value = `${requestUrl.protocol}//${requestUrl.host}`;
console.log("set instance url to:", instanceUrl.value);
}
}
}
}

View file

@ -5,10 +5,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const guildId = to.params.serverId as string;
const channels: ChannelResponse[] | undefined = await fetchChannels(guildId);
const channels: ChannelResponse[] = await fetchChannels(guildId);
console.log("channels:", channels);
if (channels && channels.length > 0) {
if (channels.length > 0) {
console.log("wah");
return await navigateTo(`/servers/${guildId}/channels/${channels[0].uuid}`, { replace: true });
}

View file

@ -22,7 +22,8 @@
"pinia-plugin-persistedstate": "^4.2.0",
"typescript": "^5.8.3",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"xxhash-wasm": "^1.1.0"
},
"packageManager": "pnpm@10.11.0",
"license": "MIT",

View file

@ -5,6 +5,7 @@
</template>
<script lang="ts" setup>
await navigateTo("/me/", { replace: true })
definePageMeta({
layout: "client"

180
pages/invite/[inviteId].vue Normal file
View file

@ -0,0 +1,180 @@
<template>
<div id="invite-root">
<div id="invite-container">
<div id="guild-container" v-if="guild">
<h1>You have been invited to {{ guild.name }}!</h1>
<div id="guild-card">
<div id="card-grid">
<div id="guild-details">
<div id="guild-name" title="Server name">
<span>{{ guild.name }}</span>
</div>
<div id="guild-member-count" :title="`${guild.member_count} members`">
<Icon name="lucide:users" />
<span>{{ guild.member_count }}</span>
</div>
</div>
<VerticalSpacer id="space" />
<div id="guild-description">
<span>{{ guild.description }}</span>
</div>
<div id="guild-icon">
<NuxtImg v-if="guild.icon" id="guild-icon-img" :src="guild.icon" :alt="`${guild.name} server icon`" />
</div>
</div>
<Button :text="isMember ? 'Joined' : 'Join'" variant="normal" :callback="acceptInvite" />
</div>
</div>
<div v-else-if="errorMessage">
<h1>{{ errorMessage }}</h1>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
import type { GuildResponse } from '~/types/interfaces';
const route = useRoute();
const { fetchInvite, joinGuild, fetchMembers } = useApi();
const { getUser } = useAuth();
const inviteId = route.params.inviteId as string;
const guild = ref<GuildResponse>();
const errorMessage = ref<string>();
const isMember = ref(false);
const accessToken = useCookie("access_token");
if (inviteId) {
try {
guild.value = await fetchInvite(inviteId);
console.log("invite guild:", guild.value);
if (accessToken.value && guild.value) {
const members = await fetchMembers(guild.value.uuid);
const me = await getUser();
if (me && members.find(member => member.user.uuid == me.uuid)) {
isMember.value = true;
}
}
} catch (error: any) {
if (error.response) {
if (error.status == 404) {
errorMessage.value = "That invite doesn't exist or has expired.";
}
}
console.error(error);
}
}
async function acceptInvite() {
if (accessToken.value && guild.value) {
await joinGuild(inviteId);
return await navigateTo(`/servers/${guild.value.uuid}`);
}
return await navigateTo(`/login?redirect_to=${route.fullPath}`);
}
</script>
<style>
#invite-root {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100dvh;
}
#invite-container {
border: .5rem solid var(--chat-highlighted-background-color);
border-radius: var(--standard-radius);
height: 50%;
width: 50%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 50%;
height: 60%;
}
#guild-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 50%;
height: 60%;
}
#guild-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background-color: var(--sidebar-highlighted-background-color);
border: .5rem solid black;
border-radius: var(--standard-radius);
padding: .5rem;
}
#card-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 5rem auto 1fr;
height: 100%;
width: 100%;
}
#guild-details {
grid-row: 1;
grid-column: span 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#guild-name {
font-size: 2rem;
flex-direction: column;
}
#guild-member-count {
gap: .3rem;
}
#space {
grid-row: 2;
grid-column: span 3;
}
#guild-description {
grid-row: 3;
grid-column: span 3;
word-break: break-all;
padding: .3rem;
}
#guild-name, #guild-member-count {
display: flex;
justify-content: center;
align-items: center;
}
#guild-icon-img {
height: 100%;
width: 100%;
object-fit: scale-down;
}
</style>

View file

@ -22,8 +22,6 @@
</template>
<script lang="ts" setup>
import type { StatsResponse } from '~/types/interfaces';
definePageMeta({
layout: "auth"
@ -38,20 +36,20 @@ const form = reactive({
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
searchParams.delete("token");
const registrationEnabled = ref<boolean>(true);
const apiBase = useCookie("api_base");
if (apiBase.value) {
console.log("apiBase:", apiBase.value);
const statsUrl = new URL("/stats", apiBase.value).href;
const { status, data } = await useFetch<StatsResponse>(statsUrl);
if (status.value == "success" && data.value) {
registrationEnabled.value = data.value.registration_enabled;
const stats = await useApi().fetchInstanceStats(apiBase.value);
if (stats) {
registrationEnabled.value = stats.registration_enabled;
}
}
const registerUrl = `/register?${searchParams}`
const registerUrl = `/register?${searchParams}`;
const { login } = useAuth();

15
pages/me/[userId].vue Normal file
View file

@ -0,0 +1,15 @@
<template>
<NuxtLayout name="client">
<DirectMessagesSidebar />
<MessageArea channel-url="channels/01970e8c-a09c-76a0-9c98-80a43364bea7"/> <!-- currently just links to the default channel -->
</NuxtLayout>
</template>
<script lang="ts" setup>
import DirectMessagesSidebar from '~/components/Me/DirectMessagesSidebar.vue';
</script>
<style>
</style>

56
pages/me/index.vue Normal file
View file

@ -0,0 +1,56 @@
<template>
<NuxtLayout name="client">
<DirectMessagesSidebar />
<div :id="$style['page-content']">
<div :id="$style['navigation-bar']">
<Button :text="`All Friends ${friends?.length}`" variant="neutral" :callback="() => updateFilter('all')" />
<Button :text="`Online ${0}`" variant="neutral" :callback="() => updateFilter('online')" />
<Button :text="`Pending ${0}`" variant="neutral" :callback="() => updateFilter('pending')" />
<Button text="Add Friend" variant="normal" :callback="() => updateFilter('add')" />
</div>
<div>
<AddFriend v-if="filter == 'add'"></AddFriend>
<FriendsList v-else :variant="filter"></FriendsList>
</div>
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import DirectMessagesSidebar from '~/components/Me/DirectMessagesSidebar.vue';
import Button from '~/components/UserInterface/Button.vue';
import AddFriend from '~/components/Me/AddFriend.vue';
import FriendsList from '~/components/Me/FriendsList.vue';
const { fetchFriends } = useApi();
let filter = ref("all");
const friends = await fetchFriends()
function updateFilter(newFilter: string) {
filter.value = newFilter;
}
</script>
<style module>
#page-content {
display: flex;
flex-direction: column;
flex-grow: 1;
margin: .75em;
}
#navigation-bar {
display: flex;
align-items: left;
text-align: left;
flex-direction: row;
gap: .5em;
}
</style>

89
pages/recover.vue Normal file
View file

@ -0,0 +1,89 @@
<template>
<NuxtLayout name="auth">
<div v-if="errorValue">{{ errorValue }}</div>
<form v-if="!emailFormSent" @submit.prevent="sendEmail">
<div>
<label for="identifier">Email or username</label>
<br>
<input type="text" name="identifier" id="identifier" v-model="emailForm.identifier">
</div>
<div>
<Button type="submit" text="Send email" variant="normal" />
</div>
</form>
<div v-else>
If an account with that username or email exists, an email will be sent to it shortly.
</div>
<div v-if="registrationEnabled">
Don't have an account? <NuxtLink :href="registerUrl">Register</NuxtLink> one!
</div>
<div>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const emailForm = reactive({
identifier: ""
});
const emailFormSent = ref(false);
const passwordForm = reactive({
password: ""
});
const errorValue = ref<string>();
const registrationEnabled = ref<boolean>(true);
const apiBase = useCookie("api_base");
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const token = ref(searchParams.get("token"))
searchParams.delete("token");
const { resetPassword } = useApi();
const registerUrl = `/register?${searchParams}`;
const loginUrl = `/login?${searchParams}`;
if (apiBase.value) {
console.log("apiBase:", apiBase.value);
const stats = await useApi().fetchInstanceStats(apiBase.value);
if (stats) {
registrationEnabled.value = stats.registration_enabled;
}
}
const { sendPasswordResetEmail } = useApi();
async function sendEmail() {
try {
await sendPasswordResetEmail(emailForm.identifier);
emailFormSent.value = true;
} catch (error) {
errorValue.value = (error as any).toString();
}
}
async function sendPassword() {
try {
console.log("pass:", passwordForm.password);
const hashedPass = await hashPassword(passwordForm.password)
console.log("hashed pass:", hashedPass);
await resetPassword(hashedPass, token.value!);
return await navigateTo(`/login?${searchParams}`);
} catch (error) {
errorValue.value = (error as any).toString();
}
}
</script>
<style>
</style>

View file

@ -1,6 +1,6 @@
<template>
<NuxtLayout>
<form v-if="registrationEnabled" @submit="register">
<form v-if="registrationEnabled && !registrationSubmitted && !showEmailVerificationScreen" @submit="register">
<div>
<!--
<span class="form-error" v-if="errors.username.length > 0">
@ -32,32 +32,88 @@
<button type="submit">Register</button>
</div>
</form>
<div v-else-if="registrationEnabled && (registrationSubmitted || showEmailVerificationScreen) && !emailSent">
<p v-if="emailVerificationRequired">
This instance requires email verification to use it.
<br><br>
<span v-if="registrationSubmitted">
Please open the link sent to your email.
</span>
<span v-else-if="showEmailVerificationScreen">
Please click on the link you've already received, or click on the button below to receive a new email.
</span>
</p>
<p v-else>
Would you like to verify your email?
<!--
<br>
This is required for resetting your password and making other important changes.
-->
</p>
<Button v-if="(!emailVerificationRequired || showEmailVerificationScreen) && !emailSent" text="Send email" variant="neutral" :callback="sendEmail"></Button>
</div>
<div v-else-if="emailSent">
<p>
An email has been sent and should arrive soon.
<br>
If you don't see it in your inbox, try checking the spam folder.
</p>
</div>
<div v-else>
<h3>This instance has disabled registration.</h3>
</div>
<div>
<div v-if="loggedIn">
<Button text="Log out" variant="scary" :callback="() => {}"></Button>
</div>
<div v-else>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import type { StatsResponse } from '~/types/interfaces';
definePageMeta({
layout: "auth"
})
});
const registrationEnabled = useState("registrationEnabled", () => true);
const emailVerificationRequired = useState("emailVerificationRequired", () => false);
const registrationSubmitted = ref(false);
const emailSent = ref(false);
const auth = useAuth();
const loggedIn = ref(await auth.getUser());
const query = new URLSearchParams(useRoute().query as Record<string, string>);
query.delete("token");
const user = await useAuth().getUser();
if (user?.email_verified) {
if (query.get("redirect_to")) {
await navigateTo(query.get("redirect_to"));
} else {
await navigateTo("/");
}
}
const showEmailVerificationScreen = query.get("special") == "verify_email";
console.log("show email verification screen?", showEmailVerificationScreen);
const { fetchInstanceStats, sendVerificationEmail } = useApi();
console.log("wah");
console.log("weoooo")
const apiBase = useCookie("api_base");
console.log("apiBase:", apiBase.value);
if (apiBase.value) {
const { status, data, error } = await useFetch<StatsResponse>(`${apiBase.value}/stats`);
if (status.value == "success" && data.value) {
registrationEnabled.value = data.value.registration_enabled;
console.log("set registration enabled value to:", data.value.registration_enabled);
const stats = await fetchInstanceStats(apiBase.value);
if (stats) {
registrationEnabled.value = stats.registration_enabled;
console.log("set registration enabled value to:", stats.registration_enabled);
emailVerificationRequired.value = stats.email_verification_required;
console.log("set email verification required value to:", stats.email_verification_required);
}
}
@ -90,8 +146,6 @@ const errorMessages = reactive({
*/
//const authStore = useAuthStore();
const auth = useAuth();
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const loginUrl = `/login?${searchParams}`
@ -133,13 +187,22 @@ async function register(e: Event) {
console.log("Sending registration data");
try {
await auth.register(form.username, form.email, form.password);
return await navigateTo(query.redirect_to);
if (!emailVerificationRequired.value) {
return await navigateTo(query.get("redirect_to"));
}
await sendVerificationEmail();
registrationSubmitted.value = true;
} catch (error) {
console.error("Error registering:", error);
}
//return navigateTo(redirectTo ? redirectTo as string : useAppConfig().baseURL as string);
}
async function sendEmail() {
await sendVerificationEmail();
emailSent.value = true;
}
</script>
<style></style>

56
pages/reset-password.vue Normal file
View file

@ -0,0 +1,56 @@
<template>
<NuxtLayout name="auth">
<div v-if="errorValue">{{ errorValue }}</div>
<form @submit.prevent="sendPassword">
<div>
<label for="password">Password</label>
<br>
<input type="password" name="password" id="password" v-model="passwordForm.password">
</div>
<div>
<Button type="submit" text="Submit" variant="normal" />
</div>
</form>
<div>
Already have an account? <NuxtLink :href="loginUrl">Log in</NuxtLink>!
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import Button from '~/components/UserInterface/Button.vue';
const query = useRoute().query as Record<string, string>;
const searchParams = new URLSearchParams(query);
const loginUrl = `/login?${searchParams}`;
const token = ref(searchParams.get("token"))
if (!token.value) await navigateTo("/login");
const passwordForm = reactive({
password: ""
});
const errorValue = ref<string>();
const { resetPassword } = useApi();
async function sendPassword() {
try {
console.log("pass:", passwordForm.password);
const hashedPass = await hashPassword(passwordForm.password)
console.log("hashed pass:", hashedPass);
await resetPassword(hashedPass, token.value!);
return await navigateTo("/login?");
} catch (error) {
errorValue.value = (error as any).toString();
}
}
</script>
<style>
</style>

View file

@ -1,38 +1,42 @@
<template>
<NuxtLayout name="client">
<ResizableSidebar
width="14rem" min-width="8rem" max-width="30rem"
border-sides="right" local-storage-name="middleLeftColumn">
<div id="middle-left-column" class="main-grid-row">
<div id="server-title">
<h3>
{{ server?.name }}
<span>
<button @click="showGuildSettings">
<Icon name="lucide:settings" />
<div id="server-name-container">
<span id="server-name" :title="server?.name">{{ server?.name }}</span>
<button id="server-settings-button" @click="toggleGuildSettings">
<Icon id="server-settings-icon" name="lucide:chevron-down" />
</button>
</span>
<span>
<button @click="toggleInvitePopup">
<Icon name="lucide:share-2" />
</button>
</span>
<InvitePopup v-if="showInvitePopup" />
</h3>
<GuildOptionsMenu v-if="showGuildSettings" />
</div>
<div id="channels-list">
<Channel v-for="channel of channels" :name="channel.name"
<ChannelEntry v-for="channel of channels" :name="channel.name"
:uuid="channel.uuid" :current-uuid="(route.params.channelId as string)"
:href="`/servers/${route.params.serverId}/channels/${channel.uuid}`" />
</div>
</div>
</ResizableSidebar>
<MessageArea :channel-url="channelUrlPath" />
<ResizableSidebar
width="14rem" min-width="5.5rem" max-width="30rem"
border-sides="left" local-storage-name="membersListWidth">
<div id="members-container">
<div id="members-list">
<MemberEntry v-for="member of members" :member="member" tabindex="0"/>
</div>
</div>
</ResizableSidebar>
</NuxtLayout>
</template>
<script lang="ts" setup>
import ChannelEntry from "~/components/Guild/ChannelEntry.vue";
import GuildOptionsMenu from "~/components/Guild/GuildOptionsMenu.vue";
import MemberEntry from "~/components/Guild/MemberEntry.vue";
import ResizableSidebar from "~/components/UserInterface/ResizableSidebar.vue";
import type { ChannelResponse, GuildMemberResponse, GuildResponse } from "~/types/interfaces";
const route = useRoute();
@ -44,32 +48,47 @@ const server = ref<GuildResponse | undefined>();
const channels = ref<ChannelResponse[] | undefined>();
const channel = ref<ChannelResponse | undefined>();
const showInvitePopup = ref(false);
const members = ref<GuildMemberResponse[]>();
import UserPopup from "~/components/UserPopup.vue";
import type { ChannelResponse, GuildMemberResponse, GuildResponse, MessageResponse } from "~/types/interfaces";
const showInvitePopup = ref(false);
const showGuildSettings = ref(false);
//const servers = await fetchWithApi("/servers") as { uuid: string, name: string, description: string }[];
//console.log("channelid: servers:", servers);
const { fetchMembers } = useApi();
const members = await fetchMembers(route.params.serverId as string);
onMounted(async () => {
console.log("channelid: set loading to true");
console.log("mounting");
const guildUrl = `guilds/${route.params.serverId}`;
server.value = await fetchWithApi(guildUrl);
console.log("fetched guild");
await setArrayVariables();
console.log("set array variables");
});
onActivated(async () => {
console.log("activating");
const guildUrl = `guilds/${route.params.serverId}`;
server.value = await fetchWithApi(guildUrl);
console.log("fetched guild");
await setArrayVariables();
console.log("set array variables");
});
async function setArrayVariables() {
members.value = sortMembers(await fetchMembers(route.params.serverId as string))
const guildUrl = `guilds/${route.params.serverId}`;
channels.value = await fetchWithApi(`${guildUrl}/channels`);
console.log("channels:", channels.value);
channel.value = await fetchWithApi(`/channels/${route.params.channelId}`);
console.log("channel:", channel.value);
}
console.log("channelid: channel:", channel);
console.log("channelid: set loading to false");
});
function showGuildSettings() { }
function toggleGuildSettings(e: Event) {
e.preventDefault();
showGuildSettings.value = !showGuildSettings.value;
}
function toggleInvitePopup(e: Event) {
e.preventDefault();
@ -81,39 +100,27 @@ function handleMemberClick(member: GuildMemberResponse) {
</script>
<style>
#middle-left-column {
padding-left: 1dvw;
padding-right: 1dvw;
border-right: 1px solid var(--padding-color);
background: var(--optional-channel-list-background);
background-color: var(--sidebar-background-color);
}
#members-container {
padding-top: 1dvh;
padding-left: 1dvw;
padding-right: 1dvw;
border-left: 1px solid var(--padding-color);
background: var(--optional-member-list-background);
}
#members-list {
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: scroll;
max-height: 92dvh;
padding-left: 1dvw;
padding-right: 1dvw;
margin-top: 1dvh;
padding-left: 1.25em;
padding-right: 1.25em;
padding-top: 0.75em;
padding-bottom: 0.75em;
max-height: calc(100% - 0.75em * 2); /* 100% - top and bottom */
}
.member-item {
display: grid;
grid-template-columns: 2dvw 1fr;
display: flex;
margin-top: .5em;
margin-bottom: .5em;
gap: 1em;
gap: .5em;
align-items: center;
text-align: left;
cursor: pointer;
@ -122,13 +129,15 @@ function handleMemberClick(member: GuildMemberResponse) {
#channels-list {
display: flex;
flex-direction: column;
gap: 1dvh;
gap: .5em;
text-overflow: ellipsis;
}
.member-avatar {
height: 2.3em;
width: 2.3em;
border-radius: 50%;
min-width: 2.3em;
max-width: 2.3em;
min-width: 2.3em;
max-height: 2.3em;
}
.member-display-name {
@ -136,4 +145,25 @@ function handleMemberClick(member: GuildMemberResponse) {
text-overflow: ellipsis;
}
#server-name-container {
padding-top: 3dvh;
padding-bottom: 3dvh;
display: flex;
justify-content: center;
position: relative;
}
#server-name {
font-size: 1.5em;
overflow: hidden;
text-overflow: ellipsis;
}
#server-settings-button {
background-color: transparent;
font-size: 1em;
color: white;
border: none;
padding: 0%;
}
</style>

View file

@ -8,7 +8,7 @@
<Icon class="back-button" size="2em" name="lucide:circle-arrow-left" alt="Back"></Icon>
</span>
</p>
<span class="spacer"></span>
<VerticalSpacer />
<!-- categories and dynamic settings pages -->
<div v-for="category in categories" :key="category.displayName">
@ -17,13 +17,13 @@
:class="{ 'sidebar-focus': selectedPage === page.displayName }">
{{ page.displayName }}
</li>
<span class="spacer"></span>
<VerticalSpacer />
</div>
<p>
<Button text="Log Out" :callback=logout variant="scary"></Button>
</p>
<span class="spacer"></span>
<VerticalSpacer />
<p id="links-and-socials">
<NuxtLink href="https://git.gorb.app/gorb/frontend" title="Source"><Icon name="lucide:git-branch-plus" /></NuxtLink>
@ -46,6 +46,13 @@
<script lang="ts" setup>
import { Profile, Account, Privacy, Devices, Connections } from '~/components/Settings/UserSettings';
import { Appearance, Notifications, Keybinds, Language } from '~/components/Settings/AppSettings';
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
import Button from '~/components/UserInterface/Button.vue';
const { logout } = useAuth()
const appConfig = useRuntimeConfig()
@ -59,17 +66,6 @@ interface Category {
pages: Page[];
}
import Profile from '~/components/Settings/UserSettings/Profile.vue';
import Account from '~/components/Settings/UserSettings/Account.vue';
import Privacy from '~/components/Settings/UserSettings/Privacy.vue';
import Devices from '~/components/Settings/UserSettings/Devices.vue';
import Connections from '~/components/Settings/UserSettings/Connections.vue';
import Appearance from '~/components/Settings/AppSettings/Appearance.vue';
import Notifications from '~/components/Settings/AppSettings/Notifications.vue';
import Keybinds from '~/components/Settings/AppSettings/Keybinds.vue';
import Language from '~/components/Settings/AppSettings/Language.vue';
const settingsCategories = {
userSettings: {
displayName: "User Settings",
@ -196,13 +192,6 @@ onMounted(() => {
margin-right: 0.2em;
}
.spacer {
height: 0.2dvh;
display: block;
margin: 0.8dvh 1dvw;
background-color: var(--padding-color);
}
/* applies to child pages too */
:deep(.subtitle) {
display: block;

View file

@ -15,6 +15,12 @@ const token = useRoute().query.token;
try {
const res = await fetchWithApi("/auth/verify-email", { query: { token } });
console.log("hi");
const query = useRoute().query;
if (query.redirect_to) {
await navigateTo(`/?redirect_to=${query.redirect_to}`);
} else {
await navigateTo("/");
}
} catch (error) {
console.error("Error verifying email:", error);
errorMessage.value = error;

8
pnpm-lock.yaml generated
View file

@ -47,6 +47,9 @@ importers:
vue-router:
specifier: ^4.5.1
version: 4.5.1(vue@3.5.13(typescript@5.8.3))
xxhash-wasm:
specifier: ^1.1.0
version: 1.1.0
devDependencies:
'@iconify-json/lucide':
specifier: ^1.2.44
@ -4744,6 +4747,9 @@ packages:
engines: {node: '>= 0.10.0'}
hasBin: true
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -10146,6 +10152,8 @@ snapshots:
cssfilter: 0.0.10
optional: true
xxhash-wasm@1.1.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}

View file

@ -1,6 +1,7 @@
:root {
--text-color: #f0e5e0;
--secondary-text-color: #e8e0db;
--reply-text-color: #969696;
--chat-background-color: #2f2e2d;
--chat-highlighted-background-color: #3f3b38;
@ -17,4 +18,11 @@
--secondary-highlighted-color: #885830;
--accent-color: #a04b24;
--accent-highlighted-color: #b86038;
--sidebar-width: 2.5em;
--standard-radius: .5em;
--button-radius: .6em;
--guild-icon-radius: 20%;
--pfp-radius: 50%;
--preferred-font: Arial;
}

View file

@ -1,6 +1,7 @@
:root {
--text-color: #f7eee8;
--secondary-text-color: #f0e8e4;
--reply-text-color: #969696;
--chat-background-color: #1f1e1d;
--chat-highlighted-background-color: #2f2b28;
@ -17,4 +18,14 @@
--secondary-highlighted-color: #8f5b2c;
--accent-color: #b35719;
--accent-highlighted-color: #c76a2e;
--sidebar-icon-width: 2.5em;
--sidebar-icon-gap: .25em;
--sidebar-margin: .5em;
--standard-radius: .5em;
--button-radius: .6em;
--guild-icon-radius: 15%;
--pfp-radius: 50%;
--preferred-font: Arial;
}

View file

@ -2,6 +2,7 @@
:root {
--text-color: #161518;
--secondary-text-color: #2b2930;
--reply-text-color: #969696;
--chat-background-color: #80808000;
--chat-highlighted-background-color: #ffffff20;
@ -19,6 +20,12 @@
--accent-color: #ff218c80;
--accent-highlighted-color: #df1b6f80;
--sidebar-width: 2.5em;
--standard-radius: .5em;
--button-radius: .6em;
--pfp-radius: 50%;
--preferred-font: Arial;
--optional-body-background: ; /* background element for the body */
--optional-chat-background: ; /* background element for the chat box */
--optional-topbar-background: ; /* background element for the topbar */

View file

@ -1,6 +1,7 @@
:root {
--text-color: #170f08;
--secondary-text-color: #2f2b28;
--reply-text-color: #969696;
--chat-background-color: #f0ebe8;
--chat-highlighted-background-color: #e8e4e0;
@ -17,4 +18,10 @@
--secondary-highlighted-color: #f8b68a;
--accent-color: #e68b4e;
--accent-highlighted-color: #f69254;
--sidebar-width: 2.5em;
--standard-radius: .5em;
--button-radius: .6em;
--pfp-radius: 50%;
--preferred-font: Arial;
}

View file

@ -1,6 +1,7 @@
:root {
--text-color: #161518;
--secondary-text-color: #2b2930;
--reply-text-color: #969696;
--chat-background-color: #80808000;
--chat-highlighted-background-color: #ffffff20;
@ -18,6 +19,12 @@
--accent-color: #ff218c80;
--accent-highlighted-color: #df1b6f80;
--sidebar-width: 2.5em;
--standard-radius: .5em;
--button-radius: .6em;
--pfp-radius: 50%;
--preferred-font: Arial;
/* --optional-body-background: background */
--optional-body-background: linear-gradient(45deg, #ed222480, #ed222480, #ed222480, #ed222480, #ed222480, #ed222480, #f35b2280, #f9962180, #f5c11e80, #f1eb1b80, #f1eb1b80, #f1eb1b80, #63c72080, #0c9b4980, #21878d80, #3954a580, #61379b80, #93288e80);
--optional-topbar-background: linear-gradient(-12.5deg, cyan, pink, white, pink, cyan);

5
types/hooks.ts Normal file
View file

@ -0,0 +1,5 @@
declare module "nuxt/schema" {
interface RuntimeNuxtHooks {
"app:message:right-clicked": (payload: { messageId: string }) => void
}
}

View file

@ -44,7 +44,8 @@ export interface MessageResponse {
channel_uuid: string,
user_uuid: string,
message: string,
user: UserResponse
reply_to: string | null,
user: UserResponse,
}
export interface InviteResponse {
@ -61,7 +62,8 @@ export interface UserResponse {
pronouns: string | null,
about: string | null,
email?: string,
email_verified?: boolean
email_verified?: boolean,
friends_since: string | null,
}
export interface StatsResponse {
@ -83,3 +85,21 @@ export interface ScrollPosition {
offsetTop: number,
offsetLeft: number
}
export interface DropdownOption {
name: string,
value: string | number,
callback: () => void
}
export interface ModalProps {
title?: string,
obscure?: boolean,
onClose?: () => void,
onCancel?: () => void
}
export interface ContextMenuItem {
name: string,
callback: (...args: any[]) => any;
}

21
types/props.ts Normal file
View file

@ -0,0 +1,21 @@
import type { MessageResponse, UserResponse } from "./interfaces";
export interface MessageProps {
class?: string,
img?: string | null,
author: UserResponse
text: string,
timestamp: number,
format: "12" | "24",
type: "normal" | "grouped",
marginBottom: boolean,
authorColor: string,
last: boolean,
messageId: string,
replyingTo?: boolean,
editing?: boolean,
me: UserResponse
message: MessageResponse,
replyMessage?: MessageResponse
isMentioned?: boolean,
}

9
types/settings.ts Normal file
View file

@ -0,0 +1,9 @@
export interface ClientSettings {
selectedThemeId?: string, // the ID of the theme, not the URL, for example "dark"
timeFormat?: TimeFormat
}
export interface TimeFormat {
index: number,
format: "auto" | "12" | "24"
}

View file

@ -0,0 +1,21 @@
import { render } from "vue";
import ContextMenu from "~/components/UserInterface/ContextMenu.vue";
import type { ContextMenuItem } from "~/types/interfaces";
export default (e: PointerEvent | MouseEvent, menuItems: ContextMenuItem[]) => {
console.log("Rendering new context menu");
const menuContainer = document.createElement("div");
console.log("hello");
menuContainer.id = "context-menu";
document.body.appendChild(menuContainer);
console.log("pointer x:", e.clientX);
console.log("pointer y:", e.clientY);
console.log("menu items:", menuItems);
const contextMenu = h(ContextMenu, {
menuItems,
pointerX: e.clientX,
pointerY: e.clientY
});
render(contextMenu, menuContainer);
console.log("Rendered");
}

24
utils/editMessage.ts Normal file
View file

@ -0,0 +1,24 @@
import type { MessageProps } from "~/types/props";
export default async (element: HTMLDivElement, props: MessageProps) => {
console.log("message:", element);
const me = await fetchWithApi("/me") as any;
if (props.author?.uuid == me.uuid) {
const text = element.getElementsByClassName("message-text")[0] as HTMLDivElement;
text.contentEditable = "true";
text.focus();
const range = document.createRange();
range.selectNodeContents(text);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.addEventListener("keyup", (e) => {
console.log("key released:", e.key);
if (e.key == "Escape") {
text.contentEditable = "false";
}
text.blur();
}, { once: true });
}
}

View file

@ -1,4 +1,4 @@
import type { NitroFetchRequest, NitroFetchOptions } from "nitropack";
import type { NitroFetchOptions } from "nitropack";
export default async <T>(path: string, options: NitroFetchOptions<string> = {}) => {
console.log("path received:", path);
@ -18,7 +18,7 @@ export default async <T>(path: string, options: NitroFetchOptions<string> = {})
return;
}
console.log("path:", path)
const { revoke, refresh } = useAuth();
const { clearAuth, refresh } = useAuth();
let headers: HeadersInit = {};
@ -61,8 +61,7 @@ export default async <T>(path: string, options: NitroFetchOptions<string> = {})
if (error?.response?.status === 401) {
console.log("Refresh returned 401");
reauthFailed = true;
console.log("Revoking");
await revoke();
await clearAuth()
console.log("Redirecting to login");
await navigateTo("/login");
console.log("redirected");

View file

@ -0,0 +1,38 @@
export default (name: string, seed: string): string => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (canvas && ctx) {
canvas.width = 256;
canvas.height = 256;
// get the first char from every word in the guild name
let previewName = "";
if (name.length > 3) {
let guildName: string[] = name.split(' ')
for (let i = 0; i < 3; i ++) {
if (guildName.length > i) {
previewName += guildName[i].charAt(0)
} else {
break
}
}
} else {
previewName = name
}
// fill background using seeded colour
ctx.fillStyle = generateIrcColor(seed, 50)
ctx.fillRect(0, 0, 256, 256)
ctx.fillStyle = 'white'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `bold 96px Arial, Helvetica, sans-serif`
// 136 isn't actually centered, but it *looks* centered
ctx.fillText(previewName, 128, 136)
return canvas.toDataURL("image/png");
}
return "https://tenor.com/view/dame-da-ne-guy-kiryukazuma-kiryu-yakuza-yakuza-0-gif-14355451116903905918"
}

13
utils/generateIrcColor.ts Normal file
View file

@ -0,0 +1,13 @@
import xxhash from "xxhash-wasm"
let h64: CallableFunction;
(async () => {
h64 = (await xxhash()).h64;
})();
export default (seed: string, saturation: number = 100, lightness: number = 50): string => {
const idHash = useState(`h64Hash-${seed}`, () => h64(seed))
const hashValue: bigint = idHash.value
return `hsl(${hashValue % 360n}, ${saturation}%, ${lightness}%)`
}

7
utils/getDisplayName.ts Normal file
View file

@ -0,0 +1,7 @@
import type { GuildMemberResponse, UserResponse } from "~/types/interfaces";
export default (user: UserResponse, member?: GuildMemberResponse): string => {
if (member?.nickname) return member.nickname
if (user.display_name) return user.display_name
return user.username
}

View file

@ -0,0 +1,11 @@
export default (): "12" | "24" => {
const format = settingsLoad().timeFormat?.format ?? "auto"
if (format == "12") {
return "12"
} else if (format == "24") {
return "24"
}
return "24"
}

View file

@ -1,4 +1,4 @@
export async function hashPassword(password: string) {
export default async (password: string) => {
const encodedPass = new TextEncoder().encode(password);
const hashBuffer = await crypto.subtle.digest("SHA-384", encodedPass);
const hashArray = Array.from(new Uint8Array(hashBuffer));

50
utils/isCanvasBlocked.ts Normal file
View file

@ -0,0 +1,50 @@
//
// Canvas Blocker &
// Firefox privacy.resistFingerprinting Detector.
// (c) 2018 // JOHN OZBAY // CRYPT.EE
// MIT License
//
export default () => {
// create a 1px image data
var blocked = false;
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
// some blockers just return an undefined ctx. So let's check that first.
if (ctx) {
var imageData = ctx.createImageData(1,1);
var originalImageData = imageData.data;
// set pixels to RGB 128
originalImageData[0]=128;
originalImageData[1]=128;
originalImageData[2]=128;
originalImageData[3]=255;
// set this to canvas
ctx.putImageData(imageData,1,1);
try {
// now get the data back from canvas.
var checkData = ctx.getImageData(1, 1, 1, 1).data;
// If this is firefox, and privacy.resistFingerprinting is enabled,
// OR a browser extension blocking the canvas,
// This will return RGB all white (255,255,255) instead of the (128,128,128) we put.
// so let's check the R and G to see if they're 255 or 128 (matching what we've initially set)
if (originalImageData[0] !== checkData[0] && originalImageData[1] !== checkData[1]) {
blocked = true;
console.log("Canvas is blocked. Will display warning.");
}
} catch (error) {
// some extensions will return getImageData null. this is to account for that.
blocked = true;
console.log("Canvas is blocked. Will display warning.");
}
} else {
blocked = true;
console.log("Canvas is blocked. Will display warning.");
}
return blocked;
}

View file

@ -0,0 +1,28 @@
let themeLinkElement: HTMLLinkElement | null;
export default function loadPreferredTheme() {
const currentTheme = settingsLoad().selectedThemeId ?? "dark"
if (themeLinkElement) {
themeLinkElement.href = getThemeUrl(currentTheme);
} else {
// create the theme link if one doesn't already exist
useHead({
link: [{
id: "main-theme",
rel: "stylesheet",
href: getThemeUrl(currentTheme)
}]
})
themeLinkElement = document.getElementById('main-theme') as HTMLLinkElement;
}
}
function getThemeUrl(id: string): string {
const runtimeConfig = useRuntimeConfig()
const baseURL = runtimeConfig.app.baseURL;
// this should preferrably use version hash, but that's not implemented yet
return `${baseURL}themes/${id}.css?v=${runtimeConfig.public.buildTimeString}`
}

View file

@ -0,0 +1,6 @@
export default () => {
const contextMenu = document.getElementById("context-menu");
if (contextMenu) {
contextMenu.remove();
}
}

14
utils/replyToMessage.ts Normal file
View file

@ -0,0 +1,14 @@
import { render } from "vue";
import MessageReply from "~/components/UserInterface/MessageReply.vue";
import type { MessageProps } from "~/types/props";
export default (element: HTMLDivElement, props: MessageProps) => {
console.log("element:", element);
const messageBox = document.getElementById("message-box") as HTMLDivElement;
if (messageBox) {
const div = document.createElement("div");
const messageReply = h(MessageReply, { author: getDisplayName(props.author), text: props.text || "", id: props.message.uuid, replyId: props.replyMessage?.uuid || element.dataset.messageId!, maxWidth: "full" });
messageBox.prepend(div);
render(messageReply, div);
}
}

21
utils/settingSave.ts Normal file
View file

@ -0,0 +1,21 @@
export default (key: string, value: any): void => {
let clientSettingsItem: string | null = localStorage.getItem("clientSettings")
if (typeof clientSettingsItem != "string") {
clientSettingsItem = "{}"
}
let clientSettings: { [key: string]: any } = {}
try {
clientSettings = JSON.parse(clientSettingsItem)
} catch {
clientSettings = {}
}
if (typeof clientSettings !== "object") {
clientSettings = {}
}
clientSettings[key] = value
localStorage.setItem("clientSettings", JSON.stringify(clientSettings))
}

21
utils/settingsLoad.ts Normal file
View file

@ -0,0 +1,21 @@
import type { ClientSettings } from "~/types/settings"
export default (): ClientSettings => {
let clientSettingsItem: string | null = localStorage.getItem("clientSettings")
if (typeof clientSettingsItem != "string") {
clientSettingsItem = "{}"
}
let clientSettings: ClientSettings = {}
try {
clientSettings = JSON.parse(clientSettingsItem)
} catch {
clientSettings = {}
}
if (typeof clientSettings !== "object") {
clientSettings = {}
}
return clientSettings
}

7
utils/sortMembers.ts Normal file
View file

@ -0,0 +1,7 @@
import type { GuildMemberResponse } from "~/types/interfaces";
export default (members: GuildMemberResponse[]): GuildMemberResponse[] => {
return members.sort((a, b) => {
return getDisplayName(a.user, a).localeCompare(getDisplayName(b.user, b))
})
}

7
utils/sortUsers.ts Normal file
View file

@ -0,0 +1,7 @@
import type { UserResponse } from "~/types/interfaces";
export default (users: UserResponse[]): UserResponse[] => {
return users.sort((a, b) => {
return getDisplayName(a).localeCompare(getDisplayName(b))
})
}

6
utils/unrender.ts Normal file
View file

@ -0,0 +1,6 @@
import { render } from "vue";
export default (div: Element) => {
render(null, div);
div.remove();
}

View file

@ -0,0 +1,3 @@
export default (username: string) => {
return /^[\w.-]+$/.test(username);
}

View file

@ -1,3 +0,0 @@
export function validateUsername(username: string) {
return /^[\w.-]+$/.test(username);
}