271 Commits

Author SHA1 Message Date
313f9fec43 Update golang to 1.26.0
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-03-07 15:49:16 +08:00
337ce15b37 Fix api user other UserInfoPayload
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-21 14:16:38 +08:00
37f06fe98a Add RetryConfig for OtelTracer
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-19 12:00:45 +08:00
79fbbd1862 Use env vars when config.yaml not exist
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 16:47:11 +08:00
e8571492f0 Fix deploy files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 14:30:26 +08:00
f17c88547b Remove client components from devenv and justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 14:17:34 +08:00
5439b6d370 Fix deploy/compose file, remove client codes (multirepo)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 14:11:03 +08:00
78fba57f94 Remove client/mobile and client/party (multirepo)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-18 09:31:51 +08:00
fa50f5c771 Modify deploy/compose.yaml (multirepo)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-17 23:11:16 +08:00
47745bbba8 Move Containerfile to root (multirepo)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-17 22:38:03 +08:00
e1709f8e50 Modify deploy Caddyfile and compose file
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-17 21:07:52 +08:00
11388c4f35 feat: scanner manual input
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 14:25:09 +08:00
b4e32d5a6d feat: implement check-in submission logic with hook and validation 2026-02-13 14:06:30 +08:00
170afb4a3b feat: scanner sidebar
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 13:25:36 +08:00
9f511c0682 refactor: improve token fetch experience and refactor spinners
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 12:46:12 +08:00
fec6fa7312 fix: event grid skeleton visual artifact
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 12:06:10 +08:00
d230d7474e refactor: improve code quality
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 11:56:25 +08:00
545facba22 fix: (hopefully) fix retry conditions
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-13 11:40:00 +08:00
550254b844 feat(ui): add loading spinners to async buttons in dialogs and forms
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 23:27:31 +08:00
cdd25236e4 fix(client): add trailing slash to redirect_uri
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 23:01:16 +08:00
ee1ff5a550 Add app to serivce_auth magic url path
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 22:49:59 +08:00
1a1f7ddaa9 fix: outer Caddyfile
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 22:41:18 +08:00
6ac2ce1197 fix(client): caddy try_file
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 22:31:38 +08:00
3c5e365e1a Modify deploy/Caddyfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 22:23:33 +08:00
73ca60e1ce feat(events): add nickname requirement dialog for events
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 22:22:00 +08:00
25a2bf75c5 feat: check-in scanner and fix bugs
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-11 21:56:04 +08:00
1a5deabadb Enforce nickname is not null after join event
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 21:51:34 +08:00
17483d31fe Enforce update nickname
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-11 21:47:25 +08:00
6a890ab17f Add debug mode for cfturnstile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 18:09:24 +08:00
0aa39ef1f4 feat(login-form): update app title and dev token default
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 18:08:56 +08:00
7dc301e9f4 fix(joined-events): update check-in button disabled state
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 17:27:54 +08:00
e6fc2f6130 refactor(event-grid): Refactor footer rendering in event grid container
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 17:25:16 +08:00
a315eea087 feat(client): checkin
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 17:16:22 +08:00
79ccd0036e Fix service_event nil kycid
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 17:07:33 +08:00
e7df62e673 Add is_checked_in into joined event api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 16:22:40 +08:00
83e62ba825 Mod swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 14:58:46 +08:00
fc29d62a00 feat(client): joined event list
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-08 14:50:34 +08:00
bd23a53fbb Add event joined router
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-08 14:49:11 +08:00
0e51c1ee39 refactor(events): move grid components from event-list to event-grid directory
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:31:49 +08:00
afbecff995 refactor(events): move grid components to event-list subdirectory
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:29:18 +08:00
c90c8da62e refactor(events): rename component to EventJoinDialogContainer
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:16:29 +08:00
cd612ab24d feat(auth): add abort controller for refresh interruption
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:12:12 +08:00
eddc23a2e8 feat(events): add event join functionality with no kyc
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-07 17:05:54 +08:00
c43c37a127 feat(client): add loading skeleton and global error handling components
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 21:36:58 +08:00
5cf00407b4 refactor(client): remove excess api version header
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:58:23 +08:00
f4a5b37892 refactor: migrate error handling to TanStack Router and add RawError type
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:55:44 +08:00
3c4e078bdd refactor(profile): update error display to use Empty component
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:47:35 +08:00
6411268090 refactor: extract empty state and update basepath to /app/
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-06 20:41:50 +08:00
0fc57ac637 Remove charts
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 18:33:21 +08:00
c9e987e2ba Add agenda service and submit api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 18:32:46 +08:00
b2f216f1bd Add lock for attendance swagger doc
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 10:52:58 +08:00
67e2cbbd04 Add attendance id resp for event join api, set root api to /app/api/v1
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-06 10:30:28 +08:00
45159484d9 feat: add empty state to events grid when no events exist
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 19:53:46 +08:00
7afc6ec25e fix(client): shit apiVersion everywhere
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
2c22c0ec5c format(client): eslint
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
33dc448871 feat(client): translate logout messages to Chinese
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
69a7756886 feat(client): add KYC for event joining
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
f793a7516f chore(client): format
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
b7ac942807 feat(client): event list
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
cee71097af feat(client): event card
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
e5c12b4cfe fix(client): sidebar should be fullscreen
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
e3df4fcf42 refactor(sidebar): split nav views and add router decorator
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
7ad479bc87 refactor(profile): split view/container and update nav state
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
ff10fe10ce feat(client): add storybook and workbench profile flow
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
22fdcd2020 fix(client): logout
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-02-05 11:22:52 +00:00
a1cac494dc Mod swagger docs for event list api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 18:28:16 +08:00
afd37c620a Fix event data FastListEvents
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 18:11:05 +08:00
f1d47a53d3 Enforce security to checkin api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 18:00:24 +08:00
8566334f59 Remove ai gen comments
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 17:44:02 +08:00
4f0b4262ed Add isjoined to event info and event list
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 17:43:20 +08:00
a7a6b7aa4e Add RequireKyc for eventinfo
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 16:47:25 +08:00
42fdceaf88 Update docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 15:22:01 +08:00
2af9d23aba Add join count to event api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 14:35:32 +08:00
e8406f731e Add checkin count in attendance and event api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 14:33:39 +08:00
050504ade6 Generate swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 12:54:27 +08:00
6504c20708 Add quota and limit for events
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-05 12:53:54 +08:00
Asai Neko
2ad3ba2400 Add swag cmd install in install-back justfile
Signed-off-by: Asai Neko <sugar@localhost.localdomain>
2026-02-03 11:55:40 +08:00
99424ee55f Add attendance_list in service_event, add set user permission in user
update

Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-02 21:31:33 +08:00
9c945d69a9 Fix Join Event service_event
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-02 19:30:40 +08:00
f5a7fa3551 Fix api event handler
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 14:01:33 +08:00
8f1d5280f7 Fix kyc info and data and api handler
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 13:58:12 +08:00
0ac96ab3e6 Add service_kyc
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 13:15:17 +08:00
a2eb882398 Add user other api logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-02-01 09:54:39 +08:00
7536fdc1ac Fix swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 12:09:14 +08:00
287f315c00 Add router for event join api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 12:00:13 +08:00
1504954be4 Add event join service and api endpoint
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 11:58:56 +08:00
82c476fa80 Add .test to gitignore
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 09:41:00 +08:00
5c6f19e8b6 Fix user other api handler
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 09:40:24 +08:00
83cec316bc Add common error user not public
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 09:08:03 +08:00
304bf0f50d Update swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:51:49 +08:00
6e88597af0 Mod service_user get_user_info other user handler
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:50:45 +08:00
8c90837a67 Fix swagget docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:49:52 +08:00
c05724a9ee Fix user other api endpoint
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:41:38 +08:00
cbc358b96e Mod get_user_info in service_user, handle isother
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:38:37 +08:00
392a15c849 Mod user other api swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:33:12 +08:00
1d885feb1f WIP Add join_event in service_event
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 08:29:02 +08:00
70d1544cfe Update swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 00:25:04 +08:00
8938fa052b Add user get other info api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-31 00:24:26 +08:00
c9775bcd8b Remove meilisearch from justfile dev back
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 13:09:35 +08:00
4715e49533 Remove meilisearch from default config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 12:55:00 +08:00
e2a8abba34 Go Mod Tidy
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 11:55:11 +08:00
39f555b780 Remove search engine, add event list api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 11:54:13 +08:00
2aa344a11f Add kyc tool library
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-30 11:27:50 +08:00
88a14bfced fix: shadcn bug
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 22:28:14 +08:00
b70095c99e feat(client): profile improvements
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 22:17:16 +08:00
5da6e9ce25 Remove Debug output for update_user_info in service_user
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 22:12:46 +08:00
3b39141bf0 Fix update_user_info avatar logic in service_user
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 21:28:42 +08:00
9016b21464 Fix service_user logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 21:14:54 +08:00
12a02d13dc Update update_user_info logic in service_user
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 20:34:46 +08:00
83bd6c2830 fix(client): relax form schema and validate on submit
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 20:23:42 +08:00
f27b991d69 Fix deploy compose file
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 13:51:51 +08:00
0f1c5b1293 Fix new user create 500 error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 13:42:00 +08:00
fabba842ce Deploy client-cms to caddy container
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 12:52:32 +08:00
5ece89268f Add ARG for client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 11:59:00 +08:00
b8c89fcf5f refactor(client): tighten env type
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 11:53:14 +08:00
65f86a8156 Set test env files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 11:48:54 +08:00
a0f6087d3e refactor(client): use generated API client and hooks
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 11:43:46 +08:00
f898243de5 Only enable swagger under debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 10:24:09 +08:00
8d8bfa3db5 Merge branch 'develop' of ssh://git.asnk.io/nixcn/nixcn-cms into develop 2026-01-29 10:20:42 +08:00
220b4d2ea3 Optimize swagger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 10:19:34 +08:00
2a0788ea86 fix(client): no grayscale on avatar
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-29 09:39:09 +08:00
3ac1f4165f Fix backend Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:35:56 +08:00
44a97c6d0f Fix backend Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:32:49 +08:00
c75423bf84 Fix backend Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:31:23 +08:00
937f382f93 Fix swagger auth docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:28:19 +08:00
654b196bfd Fix swagger data struct error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:25:12 +08:00
f7bde8ef2e Mod justfile to auto swag init when go files changed
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 01:00:51 +08:00
732d9866db Generate swagger docs
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:59:49 +08:00
330b037dca Fix stupid ai bug
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:58:28 +08:00
79dfa8499c Full Restruct API and Services
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-29 00:45:58 +08:00
89e7f1a41a WIP: Restructing auth api and service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 18:19:24 +08:00
e3c0b60337 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 16:47:27 +08:00
140a3070d6 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:42:47 +08:00
a56333fda8 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:07:40 +08:00
2b5f55f359 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:05:21 +08:00
e480bd6548 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 10:01:41 +08:00
9fb67ce2be Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:59:56 +08:00
1c7192db17 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:54:54 +08:00
9ded703143 Fix client-cms Containerfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:45:46 +08:00
3f535a8249 Split containerfile, move to container folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 09:23:59 +08:00
18fa741e4d Remove gitea actions
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 02:05:15 +08:00
4cd4a8cae6 Fix gitea workflows
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:53:36 +08:00
d90e22b641 Fix gitea workflow name
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:48:18 +08:00
4f7632af53 Fix gitea workflow
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:41:39 +08:00
ca080f4e2a Add gitea action workflow
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-28 01:36:54 +08:00
5a5239e335 Optomize user list service query bind struct
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-24 12:48:11 +08:00
314995e5f9 Finilize user api layer
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-24 12:37:17 +08:00
8e11ba4631 WIP: Full restruct, seprate service and api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-24 11:42:35 +08:00
dfd5532b20 Change default config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:39:20 +08:00
986f63c0af Add context for all exceptions
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:37:20 +08:00
154c929859 Change postgres db instance name
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:25:58 +08:00
f779435cf0 Devenv backend wait for 30s to boot
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:22:07 +08:00
5f6eb9f2a2 Trace back everything (tested)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 19:19:17 +08:00
3f44d2d9c2 Add otel tracer
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 16:59:53 +08:00
b8f89ab655 Add context for everything
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 16:43:46 +08:00
83df018d34 Only enable file log in debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:52:44 +08:00
7b3fe24b7c Add ErrorHandler for log level selects
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:48:49 +08:00
75c4edfa3d fix(client): remove console.log
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 15:38:31 +08:00
a060901cc3 refactor(client): improve token handler stability
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 15:37:04 +08:00
8e41514d05 Fix stupid ai bug
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:30:50 +08:00
9aff7d8f26 Fix 200 response exception builder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 15:26:59 +08:00
2f26b2ddb5 Fix stupid ai errors
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:58:23 +08:00
96d76b3657 feat(client): bio editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:51:03 +08:00
4e45a9b6d0 feat(client): update userinfo
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
27ac4d9b4a feat: sync api changes and fix auth-related bugs
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 14:49:49 +08:00
a60a796345 refactor: use SetError in exception.Builder where errors are available
Update multiple services and middlewares to pass the original error to exception.Builder before building the error code.

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:42:52 +08:00
14f50ecdb2 refactor: update exception constants to follow new naming convention
- Update old ErrorStatus, ErrorType, and Service/Endpoint constants to new naming convention
- Fix incorrect TypeSpecific usage in JWT middleware
- Add missing event specific error definitions to specific.yaml
- Regenerate exception constants

Co-authored-by: Gemini <gemini@google.com>
2026-01-21 14:34:09 +08:00
b1c78dce28 Add Build error hook (print exception)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:28:23 +08:00
585ec46282 Fix some type change bugs (error)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 14:16:47 +08:00
8f69b61799 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:59:03 +08:00
64bab332c9 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:58:42 +08:00
38401a5f69 Mod justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:57:06 +08:00
f03d472c30 Ignore generated go files
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:55:05 +08:00
2d6f6700f0 Move definitions to gen_exception
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:53:28 +08:00
2e11fc5d9c Fix go mod
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:51:37 +08:00
ac428946e7 Use generator to generate exceptions from yaml
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 13:48:37 +08:00
e4329dfc2b refactor: standardize error handling with exception.Builder
- Replace hardcoded error messages with structured error codes using exception.Builder.
- Introduce new common error constants in exception/common.go (CommonErrorInvalidInput, CommonErrorUserNotFound, etc.).
- Update exception/specific.go with domain-specific errors and remove redundant ones.
- Apply consistent error handling across auth, event, user services and middleware.

Co-authored-by: Gemini <gemini@google.com>
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-21 12:47:49 +08:00
5dbbdc62e6 Add exception error manager
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 12:04:17 +08:00
200614a5c9 Add error retern for database
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:03:56 +08:00
4ac5b1c101 Fix error reponses
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 10:01:13 +08:00
b7e6009706 Change logrus to slog
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:52:54 +08:00
fd262239e4 Remove file logger from config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:32:13 +08:00
cf761d218d Fix gin debug mode
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:31:24 +08:00
110627f27e Fix gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:29:01 +08:00
64392c32c6 Restruct logger order
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:25:10 +08:00
3f8f2547be Split and optimize gin logger
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:13:13 +08:00
632fa6cf8e Fix config types
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:03:12 +08:00
d04f8cdc44 Move email send from to send func
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 09:02:29 +08:00
97f5677a97 Remove oauth login email
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-21 08:58:14 +08:00
2ed4a4da02 User update check one by one
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 23:33:50 +08:00
100fe32f8e Disable email changes, lazy~~~~~
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 20:02:54 +08:00
231f591767 Fix bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:48:59 +08:00
0e7aaed154 Fix typo
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:10:00 +08:00
89c2d11f19 Fix exchange bind json error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 19:04:26 +08:00
cd93491d98 Add exchange api endpoint, fix jwt authtoken var type error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 18:51:15 +08:00
9b83ab565a Fix response structure error and router error
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 17:48:52 +08:00
5e17bbd965 Fix Containerfile using just build
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:20:44 +08:00
de0d05df0a Add charts empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 16:14:26 +08:00
b2c5f8de38 refactor(client): split client to cms/mobile/party
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 16:11:38 +08:00
ecbb890cac Add party end empty folder
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:52:48 +08:00
63f8439886 Remove justfile default and backend settings
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:50:48 +08:00
194f1fa1fe Restruct justfile
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:48:52 +08:00
55afbb29b4 Remove clean for watch-back
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:44:09 +08:00
2e76a4c6a7 Remove clean for building client and backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:42:34 +08:00
5c540db325 Add cleaning output dir for client and backend build/dev
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:40:45 +08:00
4cda783fed Fix devenv and justfile client running logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:38:40 +08:00
c4951f820a Remove unused devenv nix imports
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:35:28 +08:00
a04d562d61 Remove caddy service from devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:29:59 +08:00
f0cca0cda4 Add dev-back for justfile, just for develop backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:26:56 +08:00
087cd4ee51 Add empty test api version header checker
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:22:09 +08:00
164e271d81 Add fvm to devenv
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:17:25 +08:00
1b2933ba0e Edit mobile ignores
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:07:44 +08:00
aa85aab55e Add NixCN mobile using flutter
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 15:05:46 +08:00
197d14fb72 feat(client): pin pnpm version
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 14:33:05 +08:00
725fd18536 feat(devenv): migrate from bun to corepack/pnpm 2026-01-20 14:32:08 +08:00
ea28436628 feat(just): migrate client commands to pnpm
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 14:21:40 +08:00
7e37b92f24 Add Containerfile for production use
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-20 14:20:30 +08:00
7edcda544b feat(client): migrate to pnpm
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:53 +08:00
b8a2e24bd0 feat(client): add profile bio markdown editor
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
8e792ced68 feat(client): refactor auth/login
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
a80c3cd1dd feat(client): profile-wip
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-20 13:32:52 +08:00
67e22eb793 Go mod tidy
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-08 00:52:35 +08:00
aaedddfd2f Add Exchange SMTP Oauth2 Support (not verified)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-07 18:32:27 +08:00
f8a3d0ca45 Remove some useless comments
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 15:19:54 +08:00
6a9c013799 Use utils.HttpResponse/Abort to replace c.JSON/Abort
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 12:49:55 +08:00
70846e0d1e Reorder checkin api location (move to event)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:36:08 +08:00
0710ffce72 Tune permission level
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:27:23 +08:00
9e840901d1 Tune user API permission level
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 11:19:54 +08:00
0f1c8e327e Mod permission middleware to only request database once
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 10:40:48 +08:00
ddffb0da23 Add permission middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-06 10:36:51 +08:00
b4d0959de4 Add EnableKYC for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:48:27 +08:00
c2fd1c5cc8 Fix missed saving file (auth/redirect service)
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:22:12 +08:00
eddfa9a884 Remove jwt_secret from config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 22:02:28 +08:00
b0684492fa Change authcode using redis, authtoken use client secret to sign jwt
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 21:59:37 +08:00
aea7fddef0 Go mod tidy
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 21:31:42 +08:00
ef64c29ea7 Add Attendance state for attendance table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:24:35 +08:00
5f7f078f02 Add description for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:22:10 +08:00
1adfda54a6 Add AliId2MetaVerify OpenAPI pkg
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 16:11:08 +08:00
3510d6c1f8 Add Aliyun Id2MetaVerify encode impl
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 15:33:49 +08:00
1fa90b15c3 Add kycinfo for attendance table ane related utils
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 15:06:24 +08:00
aa8e57bd89 Add user full table api
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 14:36:10 +08:00
d6acae1625 Add owner to event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-05 14:08:20 +08:00
8dbdb58327 Add bio base64 verification
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:56:26 +08:00
61d2d2aef3 Sign new code for new redirect
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:47:15 +08:00
0b710fd538 Change magic_link_ttl old name to auth_code_ttl
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:37:30 +08:00
d70ade4907 Change resend to using smtp
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 16:26:21 +08:00
a98ab26fa4 Add oauth2 like auth service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 15:57:42 +08:00
62da1e096e Fix default config
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:59:43 +08:00
fd1c89392f Add abort for jwt middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:55:47 +08:00
ae93f49691 Fix jwt middleware cnext
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:52:04 +08:00
743f8373b0 Fix request return
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:34:13 +08:00
4796653896 Fix jwt middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:17:25 +08:00
4dfd4cd529 Modify auth middleware
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 13:00:02 +08:00
bd8eecbc7d Fix dup err logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:37:27 +08:00
cbec9bf2b3 Modify jwt middleware logic
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-02 12:36:07 +08:00
3d685b5a86 Add hot reload for backend
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 20:22:55 +08:00
83fe326962 Add event type for event table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:59:57 +08:00
5b6bc9ce42 Return user bio in user info service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:54:43 +08:00
e0e1abab93 Add Bio to user table, set varchar for role in attendance table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:52:24 +08:00
9f927c907a Fix a bug
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:27:00 +08:00
27ba3b7bef Add aes cryptography library
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:25:44 +08:00
63f71d3b81 Add bcrypt and aes crypto lib
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:24:41 +08:00
e40d175c8e Remove user.type from auth/magic service
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:12:05 +08:00
304e1d95ed Refactor checkin table to attendance table
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 14:08:59 +08:00
acd3c95c80 Refactor mass data structure
Signed-off-by: Asai Neko <sugar@sne.moe>
2026-01-01 13:31:28 +08:00
8973d518a2 refactor(client): qr dialog skeleton
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
b5b4bb9d66 refactor(client): optimize suspense components
Signed-off-by: Noa Virellia <noa@requiem.garden>
2026-01-01 03:47:55 +00:00
4c438cf4e4 Add contributing guide to README
Signed-off-by: Asai Neko <sugar@sne.moe>
2025-12-28 19:13:45 +08:00
d44eef6bb7 chore(just): do not run frontend install in backend commands
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:30 +08:00
a49450bf9e feat(auth/magic): log to console instead of sending email in debug mode
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:41:13 +08:00
228d838c37 fix(devenv): use correct just command
Signed-off-by: Noa Virellia <noa@requiem.garden>
2025-12-28 17:11:31 +08:00
194 changed files with 15939 additions and 7860 deletions

View File

@@ -1,32 +1,2 @@
SERVER_APPLICATION=nixcn-cms
SERVER_ADDRESS=:8000
SERVER_EXTERNAL_URL=http://test.sne.moe:8080
SERVER_DEBUG_MODE=true
SERVER_FILE_LOGGER=false
DATABASE_TYPE=postgres
DATABASE_HOST=localhost:5432
DATABASE_NAME=postgres
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
CACHE_HOSTS=localhost:6379
CACHE_MASTER=
CACHE_USERNAME=
CACHE_PASSWORD=
CACHE_DB=0
SEARCH_HOST=localhost
SEARCH_API_KEY=
EMAIL_RESEND_API_KEY=re_BMJaPVVB_kgdf1Go7n3dWVywp6hp4WmSA
EMAIL_FROM=NixCN CMS Email Verify <nixcn@violet.sne.moe>
SECRETS_JWT_SECRET=6Wd5xkDkF4XX5q2Ckq6TY6WX
SECRETS_TURNSTILE_SECRET=0x4AAAAAACI5pgVONOZ0rzyAYsdUcoOBF8w
TTL_MAGIC_LINK_TTL=10m
TTL_ACCESS_TTL=15s
TTL_REFRESH_TTL=168h
TTL_CHECKIN_COSE_TTL=10m
TZ=Asia/Shanghai
LOG_LEVEL=debug

6
.gitignore vendored
View File

@@ -46,3 +46,9 @@ go.work.sum
.DS_Store
__MACOSX
._*
# go gen
*_gen.go
# test files
.test/

13
Containerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM docker.io/golang:1.25.5-alpine AS backend-build
WORKDIR /app
COPY . /app
RUN go install github.com/swaggo/swag/cmd/swag@latest
RUN go mod tidy && \
go generate . && \
go build -o /app/nixcn-cms
FROM docker.io/alpine:3.23
WORKDIR /app
COPY --from=backend-build /app/nixcn-cms /app/nixcn-cms
EXPOSE 8000
ENTRYPOINT [ "/app/nixcn-cms" ]

View File

@@ -1,2 +1,25 @@
# nixcn-cms
## Contribution
1. **Root docs serve the zh-CN version** _[MUST]_
2. **Use sign-off via `git commit -s`** _[MUST]_
3. **Do not modify the `main` branch for any reason** _[MUST]_
4. **Do not omit the commit subject for any reason** _[MUST]_
5. **Describe all changes in the commit message** _[MUST]_
6. **Rebase before submitting patches** _[MUST]_
7. **Commit message written in english** _[MUST]_
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
9. **Split commits for large or multi-part changes** _[OPTION]_
10. **Have fun contributing :)** _[VERY NECESSARY]_
## Toolchain
- Nix
- Devenv
- Direnv
## Notice
1. Client and all nix files use 2 space tab.
2. All Golang files and other configs use 4 space tab.

18
api/agenda/handler.go Normal file
View File

@@ -0,0 +1,18 @@
package agenda
import (
"nixcn-cms/service/service_agenda"
"github.com/gin-gonic/gin"
)
type AgendaHandler struct {
svc service_agenda.AgendaService
}
func ApiHandler(r *gin.RouterGroup) {
agendaSvc := service_agenda.NewAgendaService()
agendaHandler := &AgendaHandler{agendaSvc}
r.POST("/submit", agendaHandler.Submit)
}

99
api/agenda/submit.go Normal file
View File

@@ -0,0 +1,99 @@
package agenda
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_agenda"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Submit handles the submission of a new agenda item.
//
// @Summary Submit Agenda
// @Description Creates a new agenda item for a specific attendance record.
// @Tags Agenda
// @Accept json
// @Produce json
// @Param body body service_agenda.SubmitData true "Agenda Submission Data"
// @Success 200 {object} utils.RespStatus{data=service_agenda.SubmitResponse}
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /agenda/submit [post]
func (self *AgendaHandler) Submit(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
data := new(service_agenda.SubmitData)
if err := c.ShouldBindJSON(data); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAgenda).
SetEndpoint(exception.EndpointAgendaServiceSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
if data.EventId.String() == "" || data.Name == "" {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAgenda).
SetEndpoint(exception.EndpointAgendaServiceSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Submit(&service_agenda.SubmitPayload{
Context: c,
UserId: userId,
Data: data,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), &result.Data)
}

87
api/auth/exchange.go Normal file
View File

@@ -0,0 +1,87 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Exchange handles the authorization code swap process.
//
// @Summary Exchange Auth Code
// @Description Exchanges client credentials and user session for a specific redirect authorization code.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.ExchangeData true "Exchange Request Credentials"
// @Success 200 {object} utils.RespStatus{data=service_auth.ExchangeResponse} "Successful exchange"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/exchange [post]
func (self *AuthHandler) Exchange(c *gin.Context) {
var exchangeData service_auth.ExchangeData
if err := c.ShouldBindJSON(&exchangeData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUnauthorized).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 401, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceExchange).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
result := self.svc.Exchange(&service_auth.ExchangePayload{
Context: c,
UserId: userId,
Data: &exchangeData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

23
api/auth/handler.go Normal file
View File

@@ -0,0 +1,23 @@
package auth
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_auth"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
svc service_auth.AuthService
}
func ApiHandler(r *gin.RouterGroup) {
authSvc := service_auth.NewAuthService()
authHandler := &AuthHandler{authSvc}
r.GET("/redirect", authHandler.Redirect)
r.POST("/magic", middleware.ApiVersionCheck(), authHandler.Magic)
r.POST("/token", middleware.ApiVersionCheck(), authHandler.Token)
r.POST("/refresh", middleware.ApiVersionCheck(), authHandler.Refresh)
r.POST("/exchange", middleware.ApiVersionCheck(), middleware.JWTAuth(), authHandler.Exchange)
}

55
api/auth/magic.go Normal file
View File

@@ -0,0 +1,55 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Magic handles the "Magic Link" authentication request.
//
// @Summary Request Magic Link
// @Description Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.MagicData true "Magic Link Request Data"
// @Success 200 {object} utils.RespStatus{data=service_auth.MagicResponse} "Successful request"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Turnstile Verification Failed"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/magic [post]
func (self *AuthHandler) Magic(c *gin.Context) {
var magicData service_auth.MagicData
if err := c.ShouldBindJSON(&magicData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceMagic).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
magicData.ClientIP = c.ClientIP()
result := self.svc.Magic(&service_auth.MagicPayload{
Context: c,
Data: &magicData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

61
api/auth/redirect.go Normal file
View File

@@ -0,0 +1,61 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Redirect handles the post-verification callback and redirects the user to the target application.
//
// @Summary Handle Auth Callback and Redirect
// @Description Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Produce json
// @Produce html
// @Param client_id query string true "Client Identifier"
// @Param redirect_uri query string true "Target Redirect URI"
// @Param code query string true "Temporary Verification Code"
// @Param state query string false "Opaque state used to maintain state between the request and callback"
// @Success 302 {string} string "Redirect to the provided RedirectUri with a new code"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input / Client Not Found / URI Mismatch"
// @Failure 403 {object} utils.RespStatus{data=nil} "Invalid or Expired Verification Code"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/redirect [get]
func (self *AuthHandler) Redirect(c *gin.Context) {
data := &service_auth.RedirectData{
ClientId: c.Query("client_id"),
RedirectUri: c.Query("redirect_uri"),
State: c.Query("state"),
Code: c.Query("code"),
}
if data.ClientId == "" || data.RedirectUri == "" || data.Code == "" {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRedirect).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(nil).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Redirect(&service_auth.RedirectPayload{
Context: c,
Data: data,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
c.Redirect(302, result.Data)
}

53
api/auth/refresh.go Normal file
View File

@@ -0,0 +1,53 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Refresh handles the token rotation process.
//
// @Summary Refresh Access Token
// @Description Accepts a valid refresh token to issue a new access token and a rotated refresh token.
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.RefreshData true "Refresh Token Body"
// @Success 200 {object} utils.RespStatus{data=service_auth.TokenResponse} "Successful rotation"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Invalid Refresh Token"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/refresh [post]
func (self *AuthHandler) Refresh(c *gin.Context) {
var refreshData service_auth.RefreshData
if err := c.ShouldBindJSON(&refreshData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceRefresh).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Refresh(&service_auth.RefreshPayload{
Context: c,
Data: &refreshData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

53
api/auth/token.go Normal file
View File

@@ -0,0 +1,53 @@
package auth
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_auth"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Token exchanges an authorization code for access and refresh tokens.
//
// @Summary Exchange Code for Token
// @Description Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
// @Tags Authentication
// @Accept json
// @Produce json
// @Param payload body service_auth.TokenData true "Token Request Body"
// @Success 200 {object} utils.RespStatus{data=service_auth.TokenResponse} "Successful token issuance"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Invalid or Expired Code"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Router /auth/token [post]
func (self *AuthHandler) Token(c *gin.Context) {
var tokenData service_auth.TokenData
if err := c.ShouldBindJSON(&tokenData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceAuth).
SetEndpoint(exception.EndpointAuthServiceToken).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Token(&service_auth.TokenPayload{
Context: c,
Data: &tokenData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

59
api/event/attendance.go Normal file
View File

@@ -0,0 +1,59 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AttendanceList handles the retrieval of the attendance list for a specific event.
//
// @Summary Get Attendance List
// @Description Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
// @Tags Event
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=[]service_event.AttendanceListResponse} "Successful retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/attendance [get]
func (self *EventHandler) AttendanceList(c *gin.Context) {
eventIdStr := c.Query("event_id")
eventId, err := uuid.Parse(eventIdStr)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceAttendanceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput). // 或者 CommonErrorUuidParseFailed
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
listData := service_event.AttendanceListData{
EventId: eventId,
}
result := self.svc.AttendanceList(&service_event.AttendanceListPayload{
Context: c,
Data: &listData,
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

177
api/event/checkin.go Normal file
View File

@@ -0,0 +1,177 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Checkin generates a check-in code for a specific event.
//
// @Summary Generate Check-in Code
// @Description Creates a temporary check-in code for the authenticated user and event.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.CheckinResponse} "Successfully generated code"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/checkin [get]
func (self *EventHandler) Checkin(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.Checkin(&service_event.CheckinPayload{
Context: c,
UserId: userId,
Data: &service_event.CheckinData{EventId: eventId},
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}
// CheckinSubmit validates a check-in code to complete attendance.
//
// @Summary Submit Check-in Code
// @Description Submits the generated code to mark the user as attended.
// @Tags Event
// @Accept json
// @Produce json
// @Param payload body service_event.CheckinSubmitData true "Checkin Code Data"
// @Success 200 {object} utils.RespStatus{data=nil} "Attendance marked successfully"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Code or Input"
// @Security ApiKeyAuth
// @Router /event/checkin/submit [post]
func (self *EventHandler) CheckinSubmit(c *gin.Context) {
var data service_event.CheckinSubmitData
if err := c.ShouldBindJSON(&data); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinSubmit).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.CheckinSubmit(&service_event.CheckinSubmitPayload{
Context: c,
Data: &data,
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
}
// CheckinQuery retrieves the check-in status of a user for an event.
//
// @Summary Query Check-in Status
// @Description Returns the timestamp of when the user checked in, or null if not yet checked in.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=service_event.CheckinQueryResponse} "Current attendance status"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 404 {object} utils.RespStatus{data=nil} "Record Not Found"
// @Security ApiKeyAuth
// @Router /event/checkin/query [get]
func (self *EventHandler) CheckinQuery(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceCheckinQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.CheckinQuery(&service_event.CheckinQueryPayload{
Context: c,
UserId: userId,
Data: &service_event.CheckinQueryData{EventId: eventId},
})
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

27
api/event/handler.go Normal file
View File

@@ -0,0 +1,27 @@
package event
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_event"
"github.com/gin-gonic/gin"
)
type EventHandler struct {
svc service_event.EventService
}
func ApiHandler(r *gin.RouterGroup) {
eventSvc := service_event.NewEventService()
eventHandler := &EventHandler{eventSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
r.GET("/info", eventHandler.Info)
r.GET("/checkin", eventHandler.Checkin)
r.GET("/checkin/query", eventHandler.CheckinQuery)
r.POST("/checkin/submit", middleware.Permission(20), eventHandler.CheckinSubmit)
r.POST("/join", eventHandler.Join)
r.GET("/list", eventHandler.List)
r.GET("/attendance", middleware.Permission(40), eventHandler.AttendanceList)
r.GET("/joined", eventHandler.Joined)
}

88
api/event/info.go Normal file
View File

@@ -0,0 +1,88 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves basic information about a specific event.
//
// @Summary Get Event Information
// @Description Fetches the name, start time, and end time of an event using its UUID.
// @Tags Event
// @Accept json
// @Produce json
// @Param event_id query string true "Event UUID"
// @Success 200 {object} utils.RespStatus{data=data.EventIndexDoc} "Successful retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "Event Not Found"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/info [get]
func (self *EventHandler) Info(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
eventIdOrig := c.Query("event_id")
eventId, err := uuid.Parse(eventIdOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
result := self.svc.GetEventInfo(&service_event.EventInfoPayload{
Context: c,
UserId: userId,
Data: &service_event.EventInfoData{
EventId: eventId,
},
})
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, 200, result.Common.Exception.String(), result.Data)
}

71
api/event/join.go Normal file
View File

@@ -0,0 +1,71 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// Join handles the request for a user to join a specific event.
//
// @Summary Join an Event
// @Description Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
// @Tags Event
// @Accept json
// @Produce json
// @Param request body service_event.EventJoinData true "Event Join Details (UserId and EventId are required)"
// @Success 200 {object} utils.RespStatus{data=service_event.EventJoinResponse} "Successfully joined the event"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input or UUID Parse Failed"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 403 {object} utils.RespStatus{data=nil} "Unauthorized / Missing User ID / Event Limit Exceeded"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error / Database Error"
// @Security ApiKeyAuth
// @Router /event/join [post]
func (self *EventHandler) Join(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceJoin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
var joinData service_event.EventJoinData
if err := c.ShouldBindJSON(&joinData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceJoin).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
joinData.UserId = userIdOrig.(string)
payload := &service_event.EventJoinPayload{
Context: c,
Data: &joinData,
}
result := self.svc.JoinEvent(payload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
}

94
api/event/joined.go Normal file
View File

@@ -0,0 +1,94 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// GetJoined retrieves a paginated list of events that the current user has joined.
//
// @Summary Get Joined Events
// @Description Fetches a list of events where the authenticated user is a participant. Supports pagination.
// @Tags Event
// @Accept json
// @Produce json
// @Param limit query int false "Maximum number of events to return (default 20)"
// @Param offset query int false "Number of events to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.EventIndexDoc} "Successful retrieval of joined events"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input"
// @Failure 401 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /event/joined [get]
func (self *EventHandler) Joined(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
type JoinedQuery struct {
Limit *string `form:"limit"`
Offset *string `form:"offset"`
}
var query JoinedQuery
if err := c.ShouldBindQuery(&query); err != nil {
exc := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Throw(c).
String()
utils.HttpResponse(c, 400, exc)
return
}
payload := &service_event.JoinedEventListPayload{
Context: c,
UserId: userId,
Data: &service_event.JoinedEventListData{
Limit: query.Limit,
Offset: query.Offset,
},
}
result := self.svc.GetJoinedEvent(payload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

99
api/event/list.go Normal file
View File

@@ -0,0 +1,99 @@
package event
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_event"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// List retrieves a paginated list of events from the database.
//
// @Summary List Events
// @Description Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
// @Tags Event
// @Accept json
// @Produce json
// @Param limit query int false "Maximum number of events to return (default 20)"
// @Param offset query int false "Number of events to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.EventIndexDoc} "Successful paginated list retrieval"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Missing offset or malformed parameters)"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database query failed)"
// @Security ApiKeyAuth
// @Router /event/list [get]
func (self *EventHandler) List(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
type ListQuery struct {
Limit *string `form:"limit"`
Offset *string `form:"offset"`
}
var query ListQuery
if err := c.ShouldBindQuery(&query); err != nil {
// Handle binding error (e.g., syntax errors in query string)
exc := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceEvent).
SetEndpoint(exception.EndpointEventServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Throw(c).
String()
utils.HttpResponse(c, 400, exc)
return
}
// Prepare payload for the service layer
eventListPayload := &service_event.EventListPayload{
Context: c,
UserId: userId,
Data: &service_event.EventListData{
Limit: query.Limit,
Offset: query.Offset,
},
}
// Call the service implementation
result := self.svc.ListEvents(eventListPayload)
// Check if the service returned any exception
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
// Return successful response with event data
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

17
api/handler.go Normal file
View File

@@ -0,0 +1,17 @@
package api
import (
"nixcn-cms/api/auth"
"nixcn-cms/api/event"
"nixcn-cms/api/kyc"
"nixcn-cms/api/user"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) {
auth.ApiHandler(r.Group("/auth"))
user.ApiHandler(r.Group("/user"))
event.ApiHandler(r.Group("/event"))
kyc.ApiHandler(r.Group("/kyc"))
}

21
api/kyc/handler.go Normal file
View File

@@ -0,0 +1,21 @@
package kyc
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_kyc"
"github.com/gin-gonic/gin"
)
type KycHandler struct {
svc service_kyc.KycService
}
func ApiHandler(r *gin.RouterGroup) {
kycSvc := service_kyc.NewKycService()
kycHandler := &KycHandler{kycSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(10))
r.POST("/session", kycHandler.Session)
r.POST("/query", kycHandler.Query)
}

66
api/kyc/query.go Normal file
View File

@@ -0,0 +1,66 @@
package kyc
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_kyc"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// @Summary Query KYC Status
// @Description Checks the current state of a KYC session and updates local database if approved.
// @Tags KYC
// @Accept json
// @Produce json
// @Param payload body service_kyc.KycQueryData true "KYC query data (KycId)"
// @Success 200 {object} utils.RespStatus{data=service_kyc.KycQueryResponse} "Query processed (success/pending/failed)"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid UUID or input"
// @Failure 403 {object} utils.RespStatus{data=nil} "Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error"
// @Security ApiKeyAuth
// @Router /kyc/query [post]
func (self *KycHandler) Query(c *gin.Context) {
_, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
var queryData service_kyc.KycQueryData
if err := c.ShouldBindJSON(&queryData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceQuery).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
queryPayload := &service_kyc.KycQueryPayload{
Context: c,
Data: &queryData,
}
result := self.svc.QueryKyc(queryPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

68
api/kyc/session.go Normal file
View File

@@ -0,0 +1,68 @@
package kyc
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_kyc"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// @Summary Create KYC Session
// @Description Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
// @Tags KYC
// @Accept json
// @Produce json
// @Param payload body service_kyc.KycSessionData true "KYC session data (Type and Base64 Identity)"
// @Success 200 {object} utils.RespStatus{data=service_kyc.KycSessionResponse} "Session created successfully"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid input or decode failed"
// @Failure 403 {object} utils.RespStatus{data=nil} "Missing User ID"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error / KYC Service Error"
// @Security ApiKeyAuth
// @Router /kyc/session [post]
func (self *KycHandler) Session(c *gin.Context) {
userIdFromHeaderOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceSession).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
var sessionData service_kyc.KycSessionData
if err := c.ShouldBindJSON(&sessionData); err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceKyc).
SetEndpoint(exception.EndpointKycServiceSession).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
sessionData.UserId = userIdFromHeaderOrig.(string)
kycPayload := &service_kyc.KycSessionPayload{
Context: c,
Data: &sessionData,
}
result := self.svc.SessionKyc(kycPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

6
api/user/create.go Normal file
View File

@@ -0,0 +1,6 @@
package user
import "github.com/gin-gonic/gin"
func (self *UserHandler) Create(c *gin.Context) {
}

24
api/user/handler.go Normal file
View File

@@ -0,0 +1,24 @@
package user
import (
"nixcn-cms/middleware"
"nixcn-cms/service/service_user"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
svc service_user.UserService
}
func ApiHandler(r *gin.RouterGroup) {
userSvc := service_user.NewUserService()
userHandler := &UserHandler{userSvc}
r.Use(middleware.ApiVersionCheck(), middleware.JWTAuth(), middleware.Permission(5))
r.GET("/info", userHandler.Info)
r.GET("/info/:user_id", userHandler.Other)
r.PATCH("/update", userHandler.Update)
r.GET("/list", middleware.Permission(20), userHandler.List)
r.POST("/create", middleware.Permission(50), userHandler.Create)
}

70
api/user/info.go Normal file
View File

@@ -0,0 +1,70 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves the profile information of the currently authenticated user.
//
// @Summary Get My User Information
// @Description Fetches the complete profile data for the user associated with the provided session/token.
// @Tags User
// @Accept json
// @Produce json
// @Success 200 {object} utils.RespStatus{data=service_user.UserInfoData} "Successful profile retrieval"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "User Not Found"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/info [get]
func (self *UserHandler) Info(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
UserInfoPayload := &service_user.UserInfoPayload{
Context: c,
UserId: userId,
IsOther: false,
Data: nil,
}
result := self.svc.GetUserInfo(UserInfoPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

61
api/user/list.go Normal file
View File

@@ -0,0 +1,61 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
)
// List retrieves a paginated list of users from the search engine.
//
// @Summary List Users
// @Description Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
// @Tags User
// @Accept json
// @Produce json
// @Param limit query string false "Maximum number of users to return (default 0)"
// @Param offset query string true "Number of users to skip"
// @Success 200 {object} utils.RespStatus{data=[]data.UserIndexDoc} "Successful paginated list retrieval"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Format Error)"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Search Engine or Missing Offset)"
// @Security ApiKeyAuth
// @Router /user/list [get]
func (self *UserHandler) List(c *gin.Context) {
type ListQuery struct {
Limit *string `form:"limit"`
Offset *string `form:"offset"`
}
var query ListQuery
if err := c.ShouldBindQuery(&query); err != nil {
exception := new(exception.Builder).
SetStatus(exception.StatusClient).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceList).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
Throw(c).
String()
utils.HttpResponse(c, 400, exception)
return
}
userListPayload := &service_user.UserListPayload{
Context: c,
Limit: query.Limit,
Offset: query.Offset,
}
result := self.svc.ListUsers(userListPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

99
api/user/other.go Normal file
View File

@@ -0,0 +1,99 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Info retrieves the profile information of the other user.
//
// @Summary Get Other User Information
// @Description Fetches the complete profile data for the user associated with the provided session/token.
// @Tags User
// @Accept json
// @Produce json
// @Param user_id path string true "Other user id"
// @Success 200 {object} utils.RespStatus{data=service_user.UserInfoData} "Successful profile retrieval"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 404 {object} utils.RespStatus{data=nil} "User Not Found"
// @Failure 403 {object} utils.RespStatus{data=nil} "User Not Public"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/info/{user_id} [get]
func (self *UserHandler) Other(c *gin.Context) {
userIdFromUrlOrig := c.Param("user_id")
userIdFromUrl, err := uuid.Parse(userIdFromUrlOrig)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
userIdFromHeaderOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userIdFromHeader, err := uuid.Parse(userIdFromHeaderOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceInfo).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
var UserInfoPayload = &service_user.UserInfoPayload{}
if userIdFromUrl == userIdFromHeader {
UserInfoPayload = &service_user.UserInfoPayload{
Context: c,
UserId: userIdFromHeader,
IsOther: false,
Data: nil,
}
} else if userIdFromUrl != userIdFromHeader {
UserInfoPayload = &service_user.UserInfoPayload{
Context: c,
UserId: userIdFromUrl,
IsOther: true,
Data: nil,
}
}
result := self.svc.GetUserInfo(UserInfoPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

84
api/user/update.go Normal file
View File

@@ -0,0 +1,84 @@
package user
import (
"nixcn-cms/internal/exception"
"nixcn-cms/service/service_user"
"nixcn-cms/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Update modifies the profile information for the currently authenticated user.
//
// @Summary Update User Information
// @Description Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
// @Description Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
// @Tags User
// @Accept json
// @Produce json
// @Param payload body service_user.UserInfoData true "Updated User Profile Data"
// @Success 200 {object} utils.RespStatus{data=nil} "Successful profile update"
// @Failure 400 {object} utils.RespStatus{data=nil} "Invalid Input (Validation Failed)"
// @Failure 401 {object} utils.RespStatus{data=nil} "Missing User ID / Unauthorized"
// @Failure 500 {object} utils.RespStatus{data=nil} "Internal Server Error (Database Error / UUID Parse Failed)"
// @Security ApiKeyAuth
// @Router /user/update [patch]
func (self *UserHandler) Update(c *gin.Context) {
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorMissingUserId).
Throw(c).
String()
utils.HttpResponse(c, 403, errorCode)
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusServer).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorUuidParseFailed).
Throw(c).
String()
utils.HttpResponse(c, 500, errorCode)
return
}
userInfoPayload := &service_user.UserInfoPayload{
Context: c,
UserId: userId,
}
err = c.ShouldBindJSON(&userInfoPayload.Data)
if err != nil {
errorCode := new(exception.Builder).
SetStatus(exception.StatusUser).
SetService(exception.ServiceUser).
SetEndpoint(exception.EndpointUserServiceUpdate).
SetType(exception.TypeCommon).
SetOriginal(exception.CommonErrorInvalidInput).
SetError(err).
Throw(c).
String()
utils.HttpResponse(c, 400, errorCode)
return
}
result := self.svc.UpdateUserInfo(userInfoPayload)
if result.Common.Exception.Original != exception.CommonSuccess {
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String())
return
}
utils.HttpResponse(c, result.Common.HttpCode, result.Common.Exception.String(), result.Data)
}

26
client/.gitignore vendored
View File

@@ -1,26 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.direnv

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,16 +0,0 @@
import antfu from '@antfu/eslint-config';
import pluginQuery from '@tanstack/eslint-plugin-query';
export default antfu({
gitignore: true,
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**'],
react: true,
stylistic: {
semi: true,
quotes: 'single',
indent: 2,
},
typescript: {
tsconfigPath: 'tsconfig.json',
},
}, ...pluginQuery.configs['flat/recommended']);

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,85 +0,0 @@
{
"name": "client",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@tanstack/react-router-devtools": "^1.141.6",
"@tanstack/react-table": "^8.21.3",
"@tanstack/zod-adapter": "^1.143.4",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"culori": "^4.0.2",
"immer": "^11.1.0",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vaul": "^1.1.2",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.1",
"@eslint-react/eslint-plugin": "^2.3.13",
"@eslint/js": "^9.39.1",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@tanstack/router-plugin": "^1.141.7",
"@types/culori": "^4.0.1",
"@types/node": "^25.0.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"lint-staged": "^16.2.7",
"simple-git-hooks": "^2.13.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-svgr": "^4.5.0"
},
"simple-git-hooks": {
"pre-commit": "bun run lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
}
}

View File

@@ -1,11 +0,0 @@
import { ThemeProvider } from '@/components/theme-provider';
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<p>Hello world</p>
</ThemeProvider>
);
}
export { App };

View File

@@ -1,28 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M120.587 309.626L148.36 261.518L269.887 472.005H214.34L186.567 423.897L158.794 472.005H131.021L117.126 447.943L158.794 375.772L120.604 309.626H120.587Z" fill="url(#paint0_linear_2199_41)"/>
<path d="M141.421 165.285H196.968L75.4412 375.772L47.6681 327.664L75.4412 279.556H19.8949L6 255.494L19.8949 231.432H103.231L141.421 165.285Z" fill="url(#paint1_linear_2199_41)"/>
<path d="M276.826 111.17L304.599 159.278H61.5632L89.3364 111.17H144.883L117.11 63.0623L131.004 39H158.778L200.446 111.17H276.826Z" fill="url(#paint2_linear_2199_41)"/>
<path d="M391.413 201.379L363.64 249.487L242.114 39H297.66L325.433 87.108L353.206 39H380.979L394.874 63.0623L353.206 135.233L391.396 201.379H391.413Z" fill="url(#paint3_linear_2199_41)"/>
<path d="M370.579 345.703H315.032L436.559 135.216L464.332 183.324L436.559 231.432H492.105L506 255.494L492.105 279.556H408.769L370.579 345.703Z" fill="url(#paint4_linear_2199_41)"/>
<path d="M235.175 399.835L207.401 351.727H450.454L422.681 399.835H367.134L394.908 447.943L381.013 472.005H353.24L311.572 399.835H235.191H235.175Z" fill="url(#paint5_linear_2199_41)"/>
<defs>
<linearGradient id="paint0_linear_2199_41" x1="-244.299" y1="-121.183" x2="-163.239" y2="19.218" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint1_linear_2199_41" x1="-194.029" y1="-258.424" x2="-275.089" y2="-118.023" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint2_linear_2199_41" x1="-50.0423" y1="-283.509" x2="-212.164" y2="-283.509" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint3_linear_2199_41" x1="43.6727" y1="-171.37" x2="-37.3876" y2="-311.771" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint4_linear_2199_41" x1="-6.58154" y1="-34.1292" x2="74.4793" y2="-174.531" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="paint5_linear_2199_41" x1="-150.568" y1="-9.02725" x2="11.5536" y2="-9.02733" gradientUnits="userSpaceOnUse">
<stop offset="1" stop-color="white"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,70 +0,0 @@
import {
IconDashboard,
IconSettings,
} from '@tabler/icons-react';
import * as React from 'react';
import NixOSLogo from '@/assets/nixos.svg?react';
import { NavMain } from '@/components/nav-main';
import { NavSecondary } from '@/components/nav-secondary';
import { NavUser } from '@/components/nav-user';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
const data = {
user: {
name: 'shadcn',
email: 'm@example.com',
avatar: '/avatars/shadcn.jpg',
},
navMain: [
{
title: '工作台',
url: '/',
icon: IconDashboard,
},
],
navSecondary: [
{
title: '设置',
url: '#',
icon: IconSettings,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<a href="#">
<NixOSLogo />
<span className="text-base font-semibold">Nix CN CMS</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -1,49 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { QRCode } from '@/components/ui/shadcn-io/qr-code';
import { useCheckinCode } from '@/hooks/data/useGetCheckInCode';
import { Button } from '../ui/button';
export function QrDialog(
{ eventId }: { eventId: string },
) {
const { data } = useCheckinCode(eventId);
return (
<Dialog>
<DialogTrigger asChild>
<Button className="w-20"></Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>QR Code</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<QrDialogContent checkinCode={data.data.checkin_code} />
</DialogContent>
</Dialog>
);
}
function QrDialogContent({ checkinCode }: { checkinCode: string }) {
return (
<>
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
<QRCode data={checkinCode} className="size-60" />
</div>
<DialogFooter>
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
{checkinCode}
</div>
</DialogFooter>
</>
);
}

View File

@@ -1,80 +0,0 @@
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate } from '@tanstack/react-router';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import NixOSLogo from '@/assets/nixos.svg?react';
import { Button } from '@/components/ui/button';
import {
Field,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
import { cn } from '@/lib/utils';
export function LoginForm({
className,
...props
}: React.ComponentProps<'div'>) {
const formRef = useRef<HTMLFormElement>(null);
const turnstileRef = useRef<TurnstileInstance>(null);
const [token, setToken] = useState<string | null>(null);
const { mutateAsync, isPending } = useGetMagicLink();
const navigate = useNavigate();
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(formRef.current!);
const email = formData.get('email')! as string;
mutateAsync({ email, turnstile_token: token! }).then(() => {
void navigate({ to: '/magicLinkSent', search: { email } });
}).catch((error) => {
console.error(error);
toast.error('请求登录链接失败');
turnstileRef.current?.reset();
});
};
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<form ref={formRef} onSubmit={handleSubmit}>
<FieldGroup>
<div className="flex flex-col items-center gap-2 text-center">
<div className="flex size-8 items-center justify-center rounded-md">
<NixOSLogo className="size-6" />
</div>
<span className="sr-only">Nix CN Meetup #2</span>
<h1 className="text-xl font-bold"> Nix CN Meetup #2</h1>
</div>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
placeholder="edolstra@gmail.com"
required
/>
</Field>
<Field>
<Button type="submit" disabled={token === null || isPending}>
{token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'}
</Button>
</Field>
</FieldGroup>
</form>
<Turnstile
ref={turnstileRef}
siteKey="0x4AAAAAACI5pu-lNWFc6Wu1"
options={{
refreshExpired: 'auto',
}}
onSuccess={(token) => {
setToken(token);
}}
/>
</div>
);
}

View File

@@ -1,45 +0,0 @@
import type { Icon } from '@tabler/icons-react';
import { Link } from '@tanstack/react-router';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: Icon;
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
{items.map(item => (
<SidebarMenuItem key={item.title}>
<Link
to={item.url}
>
{({ isActive }) => {
return (
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
);
}}
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,42 +0,0 @@
'use client';
import type { Icon } from '@tabler/icons-react';
import * as React from 'react';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: Icon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map(item => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,85 +0,0 @@
import {
IconDotsVertical,
IconLogout,
} from '@tabler/icons-react';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { useLogout } from '@/hooks/useLogout';
export function NavUser() {
const { isMobile } = useSidebar();
const { data: user } = useUserInfo();
const { logout } = useLogout();
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.nickname} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.nickname}</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<IconLogout />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@@ -1,17 +0,0 @@
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium"></h1>
</div>
</header>
);
}

View File

@@ -1,53 +0,0 @@
import type { Theme } from '@/hooks/useTheme';
import { useEffect, useState } from 'react';
import { ThemeProviderContext } from '@/hooks/useTheme';
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}
export function ThemeProvider({
children,
defaultTheme = 'dark',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// eslint-disable-next-line react/no-unstable-context-value
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext {...props} value={value}>
{children}
</ThemeProviderContext>
);
}

View File

@@ -1,53 +0,0 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,46 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,109 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -1,63 +0,0 @@
import type { VariantProps } from 'class-variance-authority';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-9 px-4 py-2 has-[>svg]:px-3',
'sm': 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
'lg': 'h-10 rounded-md px-6 has-[>svg]:px-4',
'icon': 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'>
& VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,355 +0,0 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -1,30 +0,0 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -1,141 +0,0 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,133 +0,0 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,247 +0,0 @@
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
},
);
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(async () => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map(error => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
};

View File

@@ -1,21 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -1,22 +0,0 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -1,188 +0,0 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,28 +0,0 @@
'use client';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -1,86 +0,0 @@
import { formatHex, oklch } from 'culori';
import QR from 'qrcode';
import { type HTMLAttributes, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
export type QRCodeProps = HTMLAttributes<HTMLDivElement> & {
data: string;
foreground?: string;
background?: string;
robustness?: 'L' | 'M' | 'Q' | 'H';
};
const oklchRegex = /oklch\(([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\)/;
const getOklch = (color: string, fallback: [number, number, number]) => {
const oklchMatch = color.match(oklchRegex);
if (!oklchMatch) {
return { l: fallback[0], c: fallback[1], h: fallback[2] };
}
return {
l: Number.parseFloat(oklchMatch[1]),
c: Number.parseFloat(oklchMatch[2]),
h: Number.parseFloat(oklchMatch[3]),
};
};
export const QRCode = ({
data,
foreground,
background,
robustness = 'M',
className,
...props
}: QRCodeProps) => {
const [svg, setSVG] = useState<string | null>(null);
useEffect(() => {
const generateQR = async () => {
try {
const styles = getComputedStyle(document.documentElement);
const foregroundColor =
foreground ?? styles.getPropertyValue('--foreground');
const backgroundColor =
background ?? styles.getPropertyValue('--background');
const foregroundOklch = getOklch(
foregroundColor,
[0.21, 0.006, 285.885]
);
const backgroundOklch = getOklch(backgroundColor, [0.985, 0, 0]);
const newSvg = await QR.toString(data, {
type: 'svg',
color: {
dark: formatHex(oklch({ mode: 'oklch', ...foregroundOklch })),
light: formatHex(oklch({ mode: 'oklch', ...backgroundOklch })),
},
width: 200,
errorCorrectionLevel: robustness,
margin: 0,
});
setSVG(newSvg);
} catch (err) {
console.error(err);
}
};
generateQR();
}, [data, foreground, background, robustness]);
if (!svg) {
return null;
}
return (
<div
className={cn('size-full', '[&_svg]:size-full', className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
dangerouslySetInnerHTML={{ __html: svg }}
{...(props as any)}
/>
);
};

View File

@@ -1,139 +0,0 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -1,726 +0,0 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,38 +0,0 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,83 +0,0 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -1,47 +0,0 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,30 +0,0 @@
import { Badge } from '@/components/ui/badge';
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useUserInfo } from '@/hooks/data/useUserInfo';
import { QrDialog } from '../checkin/qr-dialog';
export function CheckinCard() {
const { data } = useUserInfo();
return (
<>
<Card className="@container/card">
<CardHeader>
<CardDescription></CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{data.checkin !== null ? '已签到' : '未签到'}
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
</CardTitle>
<CardAction>
<Badge variant="outline">Day 1</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<QrDialog
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
>
</QrDialog>
</CardFooter>
</Card>
</>
);
}

View File

@@ -1,17 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useCheckinCode(eventId: string) {
return useSuspenseQuery({
queryKey: ['getCheckinCode', eventId],
queryFn: async () => {
return axiosClient.get<{
checkin_code: string;
}>('/user/checkin', {
params: {
event_id: eventId,
},
});
},
});
}

View File

@@ -1,15 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
interface GetMagicLinkPayload {
email: string;
turnstile_token: string;
}
export function useGetMagicLink() {
return useMutation({
mutationFn: async (payload: GetMagicLinkPayload) => {
return axiosClient.post<object>('/auth/magic', payload);
},
});
}

View File

@@ -1,21 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useUserInfo() {
return useSuspenseQuery({
queryKey: ['userInfo'],
queryFn: async () => {
const response = await axiosClient.get<{
user_id: string;
email: string;
type: string;
nickname: string;
subtitle: string;
avatar: string;
checkin: string | null;
}
>('/user/info');
return response.data;
},
});
}

View File

@@ -1,11 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { axiosClient } from '@/lib/axios';
export function useValidateMagicLink(ticket: string) {
return useSuspenseQuery({
queryKey: ['validateMagicLink', ticket],
queryFn: async () => {
return axiosClient.get<{ access_token: string; refresh_token: string }>('/auth/magic/verify', { params: { token: ticket } });
},
});
}

View File

@@ -1,19 +0,0 @@
import * as React from 'react';
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
}

View File

@@ -1,14 +0,0 @@
import { useNavigate } from '@tanstack/react-router';
import { useCallback } from 'react';
import { clearTokens } from '@/lib/token';
export function useLogout() {
const navigate = useNavigate();
const logout = useCallback(() => {
clearTokens();
void navigate({ to: '/login' });
}, [navigate]);
return { logout };
}

View File

@@ -1,24 +0,0 @@
import { createContext, use } from 'react';
export type Theme = 'dark' | 'light' | 'system';
interface ThemeProviderState {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function useTheme() {
const context = use(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
}

View File

@@ -1,218 +0,0 @@
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: "Inter", sans-serif;
--font-mono: "Noto Sans Mono", monospace;
--font-serif: "Lora", serif;
--radius: 0.5rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--radius: 0.5rem;
--background: oklch(0.9816 0.0017 247.839);
--foreground: oklch(0.2621 0.0095 248.1897);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2621 0.0095 248.1897);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2621 0.0095 248.1897);
--primary: oklch(0.5502 0.1193 263.8209);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.7499 0.0898 239.3977);
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
--muted: oklch(0.9417 0.0052 247.879);
--muted-foreground: oklch(0.5575 0.0165 244.8933);
--accent: oklch(0.9417 0.0052 247.879);
--accent-foreground: oklch(0.2621 0.0095 248.1897);
--destructive: oklch(0.5915 0.202 21.2388);
--border: oklch(0.9109 0.007 247.9014);
--input: oklch(1 0 0);
--ring: oklch(0.5502 0.1193 263.8209);
--chart-1: oklch(0.5502 0.1193 263.8209);
--chart-2: oklch(0.7499 0.0898 239.3977);
--chart-3: oklch(0.4711 0.0998 264.0792);
--chart-4: oklch(0.6689 0.0699 240.3096);
--chart-5: oklch(0.5107 0.1098 263.6921);
--sidebar: oklch(0.9632 0.0034 247.8585);
--sidebar-foreground: oklch(0.2621 0.0095 248.1897);
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9417 0.0052 247.879);
--sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897);
--sidebar-border: oklch(0.9109 0.007 247.9014);
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
--destructive-foreground: oklch(1 0 0);
--font-sans: "Inter", sans-serif;
--font-serif: "Lora", serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
--shadow-opacity: 0.05;
--shadow-blur: 0.5rem;
--shadow-spread: 0rem;
--shadow-offset-x: 0rem;
--shadow-offset-y: 0.1rem;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
--shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
--shadow-sm:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow-md:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
0rem 2px 4px -1px hsl(0 0% 0% / 0.05);
--shadow-lg:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
0rem 4px 6px -1px hsl(0 0% 0% / 0.05);
--shadow-xl:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
0rem 8px 10px -1px hsl(0 0% 0% / 0.05);
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.13);
--tracking-normal: 0em;
}
.dark {
--background: oklch(0.227 0.012 270.8402);
--foreground: oklch(0.9067 0 0);
--card: oklch(0.263 0.0127 258.3724);
--card-foreground: oklch(0.9067 0 0);
--popover: oklch(0.263 0.0127 258.3724);
--popover-foreground: oklch(0.9067 0 0);
--primary: oklch(0.5774 0.1248 263.377);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.7636 0.0866 239.8852);
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
--muted: oklch(0.3006 0.0156 264.3078);
--muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3006 0.0156 264.3078);
--accent-foreground: oklch(0.9067 0 0);
--destructive: oklch(0.5915 0.202 21.2388);
--border: oklch(0.3451 0.0133 248.2124);
--input: oklch(0.263 0.0127 258.3724);
--ring: oklch(0.5502 0.1193 263.8209);
--chart-1: oklch(0.5502 0.1193 263.8209);
--chart-2: oklch(0.7499 0.0898 239.3977);
--chart-3: oklch(0.4711 0.0998 264.0792);
--chart-4: oklch(0.6689 0.0699 240.3096);
--chart-5: oklch(0.5107 0.1098 263.6921);
--sidebar: oklch(0.227 0.012 270.8402);
--sidebar-foreground: oklch(0.9067 0 0);
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.3006 0.0156 264.3078);
--sidebar-accent-foreground: oklch(0.9067 0 0);
--sidebar-border: oklch(0.3451 0.0133 248.2124);
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
--destructive-foreground: oklch(1 0 0);
--radius: 0.5rem;
--font-sans: "Inter", sans-serif;
--font-serif: "Lora", serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
--shadow-opacity: 0.3;
--shadow-blur: 0.5rem;
--shadow-spread: 0rem;
--shadow-offset-x: 0rem;
--shadow-offset-y: 0.1rem;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
--shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
--shadow-sm:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
0rem 1px 2px -1px hsl(0 0% 0% / 0.3);
--shadow:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
0rem 1px 2px -1px hsl(0 0% 0% / 0.3);
--shadow-md:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
0rem 2px 4px -1px hsl(0 0% 0% / 0.3);
--shadow-lg:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
0rem 4px 6px -1px hsl(0 0% 0% / 0.3);
--shadow-xl:
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
0rem 8px 10px -1px hsl(0 0% 0% / 0.3);
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
[data-slot="dialog-overlay"] {
backdrop-filter: blur(3px);
}

View File

@@ -1,44 +0,0 @@
import type { AxiosRequestConfig } from 'axios';
import axios, { AxiosError } from 'axios';
import { router } from '@/lib/router';
import { doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token';
export const axiosClient = axios.create({
baseURL: '/api/v1/',
});
axiosClient.interceptors.request.use((config) => {
const token = getToken();
if (token !== null) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
type RetryConfig = AxiosRequestConfig & { _retry?: boolean };
axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig | undefined;
if (!error.response || error.response.status !== 401 || !originalRequest) {
return Promise.reject(error);
}
if (error.response.status === 401 && getRefreshToken() !== null) {
try {
const maybeRefreshTokenValue = await doRefreshToken();
const { access_token, refresh_token } = maybeRefreshTokenValue.data;
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization = `Bearer ${access_token}`;
setToken(access_token);
setRefreshToken(refresh_token);
return await axiosClient(originalRequest);
}
catch (e) {
if (e instanceof AxiosError && e.status === 401) {
await router.navigate({ to: '/login' });
return Promise.reject(error);
}
}
}
});

View File

@@ -1,13 +0,0 @@
import { createRouter } from '@tanstack/react-router';
// Import the generated route tree
import { routeTree } from '../routeTree.gen';
// Create a new router instance
export const router = createRouter({ routeTree });
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}

View File

@@ -1,34 +0,0 @@
import axios from 'axios';
export function setToken(token: string) {
localStorage.setItem('token', token);
}
export function getToken() {
return localStorage.getItem('token');
}
export function removeToken() {
localStorage.removeItem('token');
}
export function hasToken() {
return getToken() !== null;
}
export function setRefreshToken(refreshToken: string) {
localStorage.setItem('refreshToken', refreshToken);
}
export function getRefreshToken() {
return localStorage.getItem('refreshToken');
}
export function clearTokens() {
removeToken();
setRefreshToken('');
}
export async function doRefreshToken() {
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
}

View File

@@ -1,7 +0,0 @@
import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,15 +0,0 @@
import { RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { router } from '@/lib/router';
// Render the app
const rootElement = document.getElementById('root')!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
}

View File

@@ -1,125 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
import { Route as LoginRouteImport } from './routes/login'
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
id: '/magicLinkSent',
path: '/magicLinkSent',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
id: '/_sidebarLayout',
getParentRoute: () => rootRouteImport,
} as any)
const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SidebarLayoutRoute,
} as any)
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/': typeof SidebarLayoutIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
'/login': typeof LoginRoute
'/magicLinkSent': typeof MagicLinkSentRoute
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/login' | '/magicLinkSent' | '/'
fileRoutesByTo: FileRoutesByTo
to: '/login' | '/magicLinkSent' | '/'
id:
| '__root__'
| '/_sidebarLayout'
| '/login'
| '/magicLinkSent'
| '/_sidebarLayout/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
LoginRoute: typeof LoginRoute
MagicLinkSentRoute: typeof MagicLinkSentRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/magicLinkSent': {
id: '/magicLinkSent'
path: '/magicLinkSent'
fullPath: '/magicLinkSent'
preLoaderRoute: typeof MagicLinkSentRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout': {
id: '/_sidebarLayout'
path: ''
fullPath: ''
preLoaderRoute: typeof SidebarLayoutRouteImport
parentRoute: typeof rootRouteImport
}
'/_sidebarLayout/': {
id: '/_sidebarLayout/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
parentRoute: typeof SidebarLayoutRoute
}
}
}
interface SidebarLayoutRouteChildren {
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
}
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
}
const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
SidebarLayoutRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
LoginRoute: LoginRoute,
MagicLinkSentRoute: MagicLinkSentRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -1,22 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import '@/index.css';
const queryClient = new QueryClient();
function RootLayout() {
return (
<>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
</ThemeProvider>
<Toaster position="top-right" />
</>
);
}
export const Route = createRootRoute({ component: RootLayout });

View File

@@ -1,31 +0,0 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
export const Route = createFileRoute('/_sidebarLayout')({
component: RouteComponent,
});
function RouteComponent() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<Outlet />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,30 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { CheckinCard } from '@/components/workbenchCards/checkin';
import { hasToken } from '@/lib/token';
export const Route = createFileRoute('/_sidebarLayout/')({
component: Index,
loader: async () => {
if (!hasToken()) {
throw redirect({
to: '/login',
});
}
},
});
function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<CheckinCard />
</div>
);
}
function Index() {
return (
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import { LoginForm } from '@/components/login-form';
import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink';
import { setRefreshToken, setToken } from '@/lib/token';
const loginMagicLinkReceiverSchema = z.object({
ticket: z.string().optional(),
});
export const Route = createFileRoute('/login')({
component: RouteComponent,
validateSearch: zodValidator(loginMagicLinkReceiverSchema),
});
function ReceiveMagicLinkComponent() {
const { ticket } = Route.useSearch();
const { data } = useValidateMagicLink(ticket!);
setToken(data.data.access_token);
setRefreshToken(data.data.refresh_token);
return <Navigate to="/" />;
}
function RouteComponent() {
const { ticket } = Route.useSearch();
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
{ticket === undefined ? <LoginForm /> : <ReceiveMagicLinkComponent />}
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { createFileRoute, Navigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
import z from 'zod';
import NixOSLogo from '@/assets/nixos.svg?react';
const paramsSchema = z.object({
email: z.string().optional(),
});
export const Route = createFileRoute('/magicLinkSent')({
component: RouteComponent,
validateSearch: zodValidator(paramsSchema),
});
function RouteComponent() {
const { email } = Route.useSearch();
return email !== undefined
? (
<div className="
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
>
<NixOSLogo className="size-12" />
<span
aria-hidden="true"
className="mx-2 inline-block h-6 w-px bg-current opacity-40"
/>
{' '}
{email}
</div>
)
: <Navigate to="/login" />;
}

View File

@@ -1,32 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleDetection": "force",
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "vite-plugin-svgr/client"],
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"files": []
}

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"moduleDetection": "force",
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"types": ["node"],
"allowImportingTsExtensions": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,32 +0,0 @@
import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
svgr(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': 'http://10.0.0.10:8000',
},
host: '0.0.0.0',
port: 5173,
allowedHosts: ['test.sne.moe'],
},
});

View File

@@ -0,0 +1,14 @@
common:
success: "00000"
error:
invalid_input: "00001"
unauthorized: "00002"
internal: "00003"
permission_denied: "00004"
uuid_parse_failed: "00005"
database: "00006"
missing_user_id: "00007"
user_not_found: "00008"
user_not_public: "00009"
base64_decode_failed: "00010"
json_decode_failed: "00011"

View File

@@ -0,0 +1,33 @@
endpoint:
auth:
service:
redirect: "01"
magic: "02"
token: "03"
refresh: "04"
exchange: "05"
event:
service:
info: "01"
checkin: "02"
checkin_query: "03"
checkin_submit: "04"
list: "05"
join: "06"
attendance_list: "07"
user:
service:
info: "01"
update: "02"
list: "03"
full: "04"
create: "05"
kyc:
service:
session: "01"
query: "02"
agenda:
service:
submit: "01"
middleware:
service: "01"

View File

@@ -0,0 +1,6 @@
middleware:
service:
gin_logger: "901"
jwt: "902"
permission: "903"
api_version: "904"

View File

@@ -0,0 +1,6 @@
service:
auth: "001"
user: "002"
event: "003"
kyc: "004"
agenda: "005"

View File

@@ -0,0 +1,47 @@
api:
version:
not_found: "00001"
auth:
redirect:
token_invalid: "00001"
client_not_found: "00002"
uri_mismatch: "00003"
invalid_uri: "00004"
magic:
turnstile_failed: "00001"
code_gen_failed: "00002"
invalid_external_url: "00003"
invalid_email_config: "00004"
token:
invalid_token: "00001"
gen_failed: "00002"
refresh:
invalid_token: "00001"
renew_failed: "00002"
exchange:
get_user_id_failed: "00001"
code_gen_failed: "00002"
invalid_redirect_uri: "00003"
user:
list:
database_failed: "00001"
event:
info:
not_found: "00001"
checkin:
gen_code_failed: "00001"
checkin_query:
record_not_found: "00001"
list:
database_failed: "00001"
join:
event_invalid: "00001"
limit_exceeded: "00002"
attendance:
list_error: "00001"
kyc_info_decrypt_failed: "00002"
kyc:
session:
failed: "00001"
query:
failed: "00001"

View File

@@ -0,0 +1,5 @@
status:
success: "2"
user: "4"
server: "5"
client: "6"

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