211 Commits

Author SHA1 Message Date
101aa6a10f fix: serve frontend assets 2024-04-28 08:42:24 +08:00
399364af01 chore: update goreleaser 2024-04-27 00:11:03 +08:00
660f7fd955 chore: update goreleaser 2024-04-27 00:03:52 +08:00
43be41b8a5 chore: remove windows 2024-04-26 23:52:17 +08:00
92eaa3c613 chore: update goreleaser action version 2024-04-26 23:47:18 +08:00
17fd86726d chore: update goreleaser action version 2024-04-26 23:45:47 +08:00
393574d57d chore: add goreleaser action 2024-04-26 23:43:16 +08:00
10c94b0128 chore: add goreleaser 2024-04-26 23:37:20 +08:00
8aebafd531 feat: embed frontend dist 2024-04-26 23:31:05 +08:00
70602b8c6c feat: add last visited path 2024-04-25 21:43:17 +08:00
832b3945d9 chore: bump modernc.org/sqlite from 1.29.6 to 1.29.8 (#97)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.29.6 to 1.29.8.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.29.6...v1.29.8)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 21:12:10 +08:00
7eeef74f4c chore: bump actions/setup-go from 3 to 5 (#98)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 3 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 21:12:02 +08:00
c708585a0b chore: bump docker/build-push-action from 3 to 5 (#100)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 21:11:56 +08:00
6393531ac5 chore: bump golangci/golangci-lint-action from 3 to 4 (#99)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 3 to 4.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 21:11:49 +08:00
549dad7261 chore: bump actions/setup-node from 3 to 4 (#101)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 21:11:39 +08:00
b883905f8a chore: update dependencies 2024-04-16 21:27:47 +08:00
91e0fae1d9 chore: bump @types/react from 18.2.74 to 18.2.79 in /frontend/extension (#92)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 18.2.74 to 18.2.79.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 08:43:13 +08:00
9a2618f42b chore: upgrade jwt to v5 2024-04-16 08:42:44 +08:00
6bb99ed3cc chore: bump react-i18next from 13.5.0 to 14.1.0 in /frontend/web (#95)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 13.5.0 to 14.1.0.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v13.5.0...v14.1.0)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:48:38 +08:00
6e11c28d3e chore: bump vite from 5.2.7 to 5.2.8 in /frontend/web (#94)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.7 to 5.2.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.8/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:48:33 +08:00
f6f564913a chore: bump @types/react-dom in /frontend/extension (#93)
Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 18.2.24 to 18.2.25.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:48:26 +08:00
40deb997ea chore: bump @types/react from 18.2.74 to 18.2.79 in /frontend/web (#91)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 18.2.74 to 18.2.79.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:48:13 +08:00
59118109bd chore: bump typescript from 5.4.4 to 5.4.5 in /frontend/extension (#90)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.4 to 5.4.5.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.4...v5.4.5)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:48:08 +08:00
971eb4e8f7 chore: bump actions/checkout from 3 to 4 (#84)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:47:52 +08:00
1c70d9484e chore: bump @types/chrome from 0.0.241 to 0.0.266 in /frontend/extension (#89)
Bumps [@types/chrome](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/chrome) from 0.0.241 to 0.0.266.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/chrome)

---
updated-dependencies:
- dependency-name: "@types/chrome"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:47:39 +08:00
6a8c07f93a chore: bump eslint-config-prettier from 8.10.0 to 9.1.0 in /frontend/web (#88)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 8.10.0 to 9.1.0.
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v8.10.0...v9.1.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:47:31 +08:00
0ef6a6038a chore: bump @types/node from 20.12.4 to 20.12.7 in /frontend/extension (#87)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.12.4 to 20.12.7.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:47:26 +08:00
dd521103c9 chore: bump docker/login-action from 2 to 3 (#86)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:47:18 +08:00
b04ea04062 chore: bump docker/setup-qemu-action from 2 to 3 (#85)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:47:12 +08:00
1774e525b3 chore: bump pnpm/action-setup from 2.2.4 to 3.0.0 (#83)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2.2.4 to 3.0.0.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v2.2.4...v3.0.0)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:46:55 +08:00
d502e3ce74 chore: bump docker/setup-buildx-action from 2 to 3 (#82)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 07:46:27 +08:00
80e52829fa chore: add dependabot 2024-04-16 07:40:29 +08:00
c61aa8020a chore: update backend dependencies 2024-04-16 07:40:17 +08:00
d2d63836d4 chore: tweak api name 2024-04-15 22:52:21 +08:00
e0ad25b2c6 chore: tweak readme 2024-04-08 22:37:36 +08:00
cc669f1be0 chore: tweak frontend imports 2024-04-08 22:30:28 +08:00
5bf86601e6 chore: tweak favicon provider description 2024-04-07 21:58:52 +08:00
b7484363dc chore: tweak store imports 2024-04-07 21:23:14 +08:00
5264dc9d8a feat(web): use favicon provider 2024-04-07 20:28:21 +08:00
8649e562dc refactor: update imports 2024-04-07 19:35:13 +08:00
905b962e0b chore: add favicon provider to workspace setting section 2024-04-07 00:16:42 +08:00
07c863b251 chore: add favicon provider workspace setting 2024-04-06 23:58:56 +08:00
69f2c7ad89 chore: bump extension version 2024-04-05 23:02:04 +08:00
e2c7b8c7b9 feat: add default visibility setting to extension 2024-04-05 22:54:03 +08:00
c6821a7090 chore: update dependencies 2024-04-05 10:28:50 +08:00
b1125f3727 chore: update dependencies 2024-04-02 23:40:38 +08:00
c7af8d6afa chore: move frontend package 2024-03-26 20:35:36 +08:00
1025d8a2ed chore: tweak styles 2024-03-25 21:48:23 +08:00
3f3d7a4c58 chore(deps): bump follow-redirects in /frontend/extension (#75)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-25 11:30:32 +08:00
80f0af8723 chore: update backend dependencies 2024-03-24 22:25:07 +08:00
730cff1148 fix: license time parser 2024-03-24 21:56:07 +08:00
0e3481b593 chore: handle unspecified visibility 2024-03-20 00:07:23 +08:00
abacc9af8b chore: remove default visibility field 2024-03-20 00:07:07 +08:00
d837cbd0ff chore: tweak words 2024-03-18 10:07:02 +08:00
3f7abce427 chore: add openapiv2 docs 2024-03-17 20:20:52 +08:00
b6bcc3cda6 chore: tweak logo 2024-03-17 19:52:59 +08:00
07d9436e1e chore: code clean 2024-03-12 21:45:19 +08:00
5c1c238453 chore: remove unused log package 2024-03-06 00:45:35 +08:00
02fb415260 chore: update server logger 2024-03-06 00:43:13 +08:00
d866268a7a chore: tweak page styles 2024-03-06 00:35:04 +08:00
98d73e81c0 chore: update dependencies 2024-03-06 00:29:34 +08:00
47821879fa chore: add tag input 2024-03-06 00:22:39 +08:00
7c16b1e00f chore: update frontend buf generator 2024-03-06 00:22:31 +08:00
29043f63b6 chore: fix set state 2024-03-05 22:26:52 +08:00
87d626cd1d chore: move api to route package 2024-03-01 00:08:05 +08:00
201cf83afe chore: bump extension version 2024-02-27 00:38:25 +08:00
35de611fd1 fix: api base url 2024-02-27 00:37:33 +08:00
5c02bb98bf chore: upgrade backend dependencies 2024-02-19 21:39:23 +08:00
c1f915ae31 chore: tweak golangci 2024-02-19 21:15:32 +08:00
faae146a86 chore: bump version 2024-02-19 21:13:10 +08:00
4a6c6b4b2a feat: add default visibility workspace setting 2024-02-19 21:11:09 +08:00
fafacc92eb refactor: update api version 2024-02-19 20:45:54 +08:00
b5f5ae2483 chore: update title dark mode style 2024-02-07 21:11:02 +08:00
cdfb015638 chore: bump version 2024-02-06 21:19:15 +08:00
435fe04ab3 chore: fix shortcut space checks 2024-02-06 21:15:58 +08:00
4e73882bf1 chore: add postinstall script 2024-02-06 21:10:34 +08:00
f2d9b29baa fix: delete user 2024-02-06 21:10:31 +08:00
eaf9113c92 fix: public shortcuts not showing up in public collections (#63) 2024-02-04 09:48:13 +08:00
194571e132 chore: remove debug code 2024-01-28 15:45:18 +08:00
f34b33c1a2 chore: upgrade version to v0.5.2 2024-01-21 19:18:19 +08:00
2552a7645e chore: update shortcuts display 2024-01-21 18:49:42 +08:00
5dab623793 chore: update logo 2024-01-21 18:42:47 +08:00
95f1570796 feat: allow to create shortcut if not found 2024-01-21 17:33:21 +08:00
85848ee317 chore: update default visibility 2024-01-21 17:07:59 +08:00
385499e642 chore: upgrade dependencies 2024-01-21 17:00:31 +08:00
59ee192bf8 chore: add random name button 2024-01-10 10:50:02 +08:00
2771602e5c chore: add enable metric flag 2024-01-10 10:20:16 +08:00
38cd5fabee chore: update store testing 2024-01-10 10:08:53 +08:00
4bc2a0ff42 chore: add healthz point 2024-01-10 00:28:57 +08:00
d46e83b735 chore: update backend dependencies 2023-12-28 08:38:20 +08:00
a37fce2849 chore: update frontend dependencies 2023-12-28 08:38:08 +08:00
764d776524 chore: redirect to instance url 2023-12-24 09:50:10 +08:00
7c9798b6b1 chore: upgrader to 0.5.1 2023-12-24 09:17:53 +08:00
70b0645f2e chore: update shortcut card style 2023-12-24 09:17:41 +08:00
88606e0a0c chore: update embed frontend 2023-12-24 01:05:15 +08:00
91708da5fc chore: update dev version 2023-12-24 00:32:29 +08:00
867d150a6d chore: fix member list 2023-12-24 00:26:49 +08:00
9259a85e69 chore: fix create shortcut view activity 2023-12-24 00:22:03 +08:00
546d87ca0b fix: update shortcut 2023-12-23 23:52:44 +08:00
b73f7070e4 chore(deps): bump golang.org/x/crypto from 0.14.0 to 0.17.0 (#53)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.14.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.14.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-22 15:03:53 +08:00
bec2c15ac9 fix: list activities 2023-12-20 08:01:27 +08:00
d4c7de3916 chore: update postgres profile 2023-12-19 08:59:07 +08:00
47346182f0 chore: fix view shortcut activity 2023-12-18 08:44:56 +08:00
aa1351f815 chore: remove gzip middleware 2023-12-17 23:48:04 +08:00
997b057a21 chore: add html escaping 2023-12-17 23:37:19 +08:00
fb7fc2443f chore: add shortcut space routes 2023-12-17 23:27:01 +08:00
43cda4e2fb chore: update subscription setting 2023-12-17 22:48:08 +08:00
dbd3888fe1 feat: implement frontend ssr service 2023-12-17 21:56:10 +08:00
6eb3ff412d chore: fix tests 2023-12-17 20:10:12 +08:00
4c66edc170 chore: fix postgres driver 2023-12-17 20:07:25 +08:00
a7d48e8059 feat: initial postgres driver 2023-12-17 15:08:51 +08:00
41cb597f03 chore: update sqlite functions 2023-12-17 14:16:34 +08:00
a9071d629a chore: fix testing profile 2023-12-17 13:59:15 +08:00
9173c8f19a feat: abstract database drivers 2023-12-17 13:56:41 +08:00
6350b19478 chore: upgrade frontend deps 2023-12-10 19:11:06 +08:00
8b13c94b22 chore: implement memo service 2023-12-10 17:06:12 +08:00
add523f8a5 feat: initial memo store 2023-12-10 16:57:01 +08:00
5c3df55b72 chore: tweak collection view styles 2023-12-10 16:08:15 +08:00
5f69ab67df chore: tweak dialog styles 2023-12-10 15:47:01 +08:00
e7d2bd0be6 chore: add auth status api 2023-12-10 15:17:00 +08:00
9ac6188707 chore: tweak styles 2023-12-10 15:10:53 +08:00
3d109dc1b4 chore(deps-dev): bump vite from 4.5.0 to 4.5.1 in /frontend/web (#48)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-10 10:36:56 +08:00
c45a48966d chore: remove the "-temp" part of the shortcut name in the extension (#47)
* increase random id size in shortcut

* remove the temp part of the shortcut name
2023-12-08 09:38:57 +08:00
263812f98f chore: update package name 2023-11-25 10:40:16 +08:00
b7999a4db2 chore: update auth service 2023-11-23 22:02:19 +08:00
38e5398cb9 chore: fix error message 2023-11-23 20:46:39 +08:00
a4e91541cf chore: add cookies 2023-11-23 20:06:42 +08:00
5e227da0c4 chore: add docs for legacy api 2023-11-22 20:28:36 +08:00
59e1281960 revert: chore: remove deperecated api 2023-11-22 20:27:11 +08:00
01e49e23b5 chore: remove deperecated api 2023-11-22 20:26:26 +08:00
2f30162add chore: migrate auth service 2023-11-22 20:16:26 +08:00
3be52e7ab8 chore: migrate shortcut analytics 2023-11-22 19:47:23 +08:00
c85442d39f chore: fix update shortcut 2023-11-22 19:18:33 +08:00
61b167ef66 chore: use shortcut v2 api 2023-11-21 23:33:34 +08:00
c449669793 chore: fix update shortcut 2023-11-21 22:32:55 +08:00
0c2283a831 chore: add version to workspace profile 2023-11-21 22:12:12 +08:00
50d9873ec1 chore: tweak shortcut link display 2023-11-21 22:08:46 +08:00
0b5f54b5b2 chore: update favicon rounded 2023-11-21 21:41:21 +08:00
ec581076ef chore: update logo 2023-11-21 21:22:39 +08:00
c71575faed chore: user user store 2023-11-21 21:00:36 +08:00
35785a1a28 chore(frontend): update user module 2023-11-20 23:14:16 +08:00
832eb7cbf1 chore: use resource name input for collection 2023-11-20 21:40:43 +08:00
65504cf537 chore: update shortcut name input 2023-11-20 21:35:47 +08:00
6fa1c30fb7 chore: update collection checks 2023-11-20 20:46:54 +08:00
d18872aa5f chore: update shortcut form fields 2023-11-19 23:57:09 +08:00
5f94f3f893 chore: update extension version 2023-11-19 23:34:38 +08:00
b03c94f75d chore: update resource description in extension 2023-11-19 23:34:00 +08:00
e9905cbc39 chore: update shortcut name checks 2023-11-19 23:25:43 +08:00
fdee03cc99 chore: remove omnibox 2023-11-19 23:25:36 +08:00
cb98be1891 chore: update resource folder 2023-11-16 08:31:08 +08:00
80edd1b9a9 chore: tweak readme links 2023-11-16 08:21:43 +08:00
91ad30ae27 chore: update readme 2023-11-16 08:20:59 +08:00
168ad39076 chore: add workspace visibility to collection 2023-11-16 08:16:06 +08:00
0a62579814 chore: update shortcut frame 2023-11-16 08:05:33 +08:00
3da0e4720e chore: add collection seed data 2023-11-16 07:38:09 +08:00
dad0d91d01 chore: update collection detail page 2023-11-15 23:44:58 +08:00
92635fe395 chore: update list collections api 2023-11-15 23:31:47 +08:00
fbc089569d feat: add search box in collection dashboard 2023-11-15 22:54:28 +08:00
2296eb96ef chore: use drawer instead of dialog 2023-11-15 22:14:36 +08:00
30d9dd04bb chore: update collection docs 2023-11-12 16:54:30 +08:00
e89358cb0a chore: fix error message 2023-11-12 16:39:19 +08:00
bbe2bdffe3 docs: update getting started 2023-11-12 16:38:26 +08:00
af9655eeaf chore: fix user message 2023-11-12 15:49:00 +08:00
916423cc89 chore: check selected shortcuts 2023-11-12 14:02:39 +08:00
dddb643bed chore: fix creator initial 2023-11-12 14:01:00 +08:00
0f7a771e85 chore: update collection metric 2023-11-12 13:41:36 +08:00
8d8b892d2a chore: update shortcut details 2023-11-12 13:29:07 +08:00
8a4e07120f chore: update collection details 2023-11-12 12:57:39 +08:00
8de658709c docs: add getting started placeholder 2023-11-12 12:29:00 +08:00
cb3e3bfaef chore: update shortcut view 2023-11-12 11:34:09 +08:00
4a25fbb2f6 chore(deps): bump axios from 0.27.2 to 1.6.0 in /frontend/web (#43)
Bumps [axios](https://github.com/axios/axios) from 0.27.2 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.27.2...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-12 10:47:58 +08:00
83970d5d55 feat: add collection views 2023-11-12 10:47:49 +08:00
626b0df21c chore: update acl config 2023-11-12 01:43:56 +08:00
8f608dc522 chore: fix collection service 2023-11-11 21:06:52 +08:00
8f982c5695 chore: update lucide 2023-11-10 22:57:01 +08:00
94baa04bb1 chore: update license 2023-11-10 22:56:48 +08:00
1505e9fa56 chore: fix extension build 2023-11-10 11:18:58 +08:00
cab701f11b chore: impl collection service 2023-11-10 11:15:53 +08:00
a3743d7ac6 chore: add collection service definition 2023-11-10 11:02:12 +08:00
7715905204 feat: impl collection store 2023-11-10 10:41:52 +08:00
f770149066 chore: update collection store definition 2023-11-10 10:18:55 +08:00
f3f2218e91 chore(frontend): update shortcut store 2023-11-10 10:11:02 +08:00
b3e766926d chore: update shortcut definition 2023-11-10 09:54:05 +08:00
6ed9ecffde chore: update page max width 2023-11-10 09:20:07 +08:00
c8d8c4e40c chore: update contact 2023-11-10 09:09:00 +08:00
4f94927b5c chore: use protobuf timestamp 2023-11-10 09:02:28 +08:00
f5f8616f2e feat: add update shortcut api 2023-11-10 08:51:19 +08:00
033c007654 chore: add collection table definition 2023-11-10 08:19:30 +08:00
0fb5377226 chore: upgrade frontend deps 2023-11-09 20:45:18 +08:00
f0afa13b8d chore: update extension with web request listener 2023-11-09 19:10:10 +08:00
53df3a9c1c chore: go mod tidy 2023-11-07 07:43:35 +08:00
8faaf8ced1 chore: update logger 2023-11-07 07:43:18 +08:00
67c3bbf1ee chore: update deps 2023-11-07 07:36:50 +08:00
68745ba9e0 chore: upgrade backend deps 2023-10-31 09:07:22 +08:00
015336b8c3 chore: combine v2 services 2023-10-31 08:53:40 +08:00
82ac6ab985 chore: update test 2023-10-31 08:43:30 +08:00
898ca70ad1 chore: upgrade frontend deps 2023-10-31 08:35:32 +08:00
5b2a8394d7 chore: update golangci-lint 2023-10-21 16:05:34 +08:00
16e17bffb3 chore: fix update resource api 2023-10-21 15:56:27 +08:00
015040cc1d chore: upgrade version 2023-10-17 23:02:07 +08:00
c8869e67c7 chore: update font family 2023-10-17 22:59:39 +08:00
a9ae7d2e96 chore: update frontend deps 2023-10-17 22:30:19 +08:00
db9034ccf9 chore: update buf deps 2023-10-17 22:01:00 +08:00
4d1705dca5 chore: update bin folder 2023-10-17 21:56:24 +08:00
3225e7c47b chore: update server structure 2023-10-17 21:54:22 +08:00
328397612c chore(deps): bump golang.org/x/net from 0.12.0 to 0.17.0 (#39)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.12.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.12.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-12 11:05:52 -05:00
c846cde5b4 chore: update readme 2023-10-05 08:32:26 +08:00
5c2cb99866 chore: update logo image 2023-10-05 08:31:39 +08:00
742c7da2eb fix: i18n AMO links (#38) 2023-10-01 08:20:34 -05:00
88b247410f chore: update readme about extension 2023-10-01 12:53:56 +08:00
01417943fb chore: update readme about extension 2023-10-01 12:50:34 +08:00
283 changed files with 29797 additions and 17850 deletions

26
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,26 @@
version: 2
updates:
- package-ecosystem: "github-actions"
commit-message:
prefix: "chore"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: npm
commit-message:
prefix: "chore"
directory: "/frontend/web"
schedule:
interval: "weekly"
- package-ecosystem: npm
commit-message:
prefix: "chore"
directory: "/frontend/extension"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
commit-message:
prefix: "chore"
directory: "/"
schedule:
interval: "weekly"

View File

@ -12,10 +12,10 @@ jobs:
go-static-checks: go-static-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-go@v3 - uses: actions/setup-go@v5
with: with:
go-version: 1.21 go-version: 1.22
check-latest: true check-latest: true
cache: true cache: true
- name: Verify go.mod is tidy - name: Verify go.mod is tidy
@ -23,18 +23,19 @@ jobs:
go mod tidy go mod tidy
git diff --exit-code git diff --exit-code
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v3 uses: golangci/golangci-lint-action@v4
with: with:
args: -v version: v1.54.1
args: --verbose --timeout=3m
skip-cache: true skip-cache: true
go-tests: go-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-go@v3 - uses: actions/setup-go@v5
with: with:
go-version: 1.21 go-version: 1.22
check-latest: true check-latest: true
cache: true cache: true
- name: Run all tests - name: Run all tests

View File

@ -10,10 +10,10 @@ jobs:
build-and-push-release-image: build-and-push-release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Extract build args - name: Extract build args
# Extract version from branch name # Extract version from branch name
@ -22,20 +22,20 @@ jobs:
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: yourselfhosted username: yourselfhosted
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
with: with:
install: true install: true
- name: Build and Push - name: Build and Push
id: docker_build id: docker_build
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile

View File

@ -8,27 +8,27 @@ jobs:
build-and-push-test-image: build-and-push-test-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: yourselfhosted username: yourselfhosted
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
with: with:
install: true install: true
version: v0.9.1 version: v0.9.1
- name: Build and Push - name: Build and Push
id: docker_build id: docker_build
uses: docker/build-push-action@v3 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile

33
.github/workflows/build-artifacts.yml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Build artifacts
on:
push:
tags:
- "*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: 1.22
check-latest: true
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
# either 'goreleaser' (default) or 'goreleaser-pro'
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -14,14 +14,11 @@ jobs:
eslint-checks: eslint-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1 - uses: pnpm/action-setup@v3.0.0
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 9
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm
@ -35,14 +32,11 @@ jobs:
extension-build: extension-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1 - uses: pnpm/action-setup@v3.0.0
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 9
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm

View File

@ -14,14 +14,11 @@ jobs:
eslint-checks: eslint-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1 - uses: pnpm/action-setup@v3.0.0
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 9
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm
@ -35,14 +32,11 @@ jobs:
frontend-build: frontend-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: bufbuild/buf-setup-action@v1 - uses: pnpm/action-setup@v3.0.0
- run: buf generate
working-directory: proto
- uses: pnpm/action-setup@v2.2.4
with: with:
version: 8 version: 9
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
node-version: "18" node-version: "18"
cache: pnpm cache: pnpm

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup buf - name: Setup buf

4
.gitignore vendored
View File

@ -10,3 +10,7 @@ build
.DS_Store .DS_Store
node_modules node_modules
.env
dist/

View File

@ -29,7 +29,7 @@ issues:
linters-settings: linters-settings:
goimports: goimports:
# Put imports beginning with prefix after 3rd-party packages. # Put imports beginning with prefix after 3rd-party packages.
local-prefixes: github.com/boojack/slash local-prefixes: github.com/yourselfhosted/slash
revive: revive:
# Default to run all linters so that new rules in the future could automatically be added to the static check. # Default to run all linters so that new rules in the future could automatically be added to the static check.
enable-all-rules: true enable-all-rules: true
@ -65,6 +65,10 @@ linters-settings:
disabled: true disabled: true
- name: early-return - name: early-return
disabled: true disabled: true
- name: use-any
disabled: true
- name: var-naming
disabled: true
- name: exported - name: exported
arguments: arguments:
- "disableStutteringCheck" - "disableStutteringCheck"

38
.goreleaser.yaml Normal file
View File

@ -0,0 +1,38 @@
version: 1
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
main: ./bin/slash
binary: slash
goos:
- linux
- darwin
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
checksum:
disable: true
release:
draft: true
replace_existing_draft: true
make_latest: true
mode: replace
skip_upload: false

View File

@ -1,37 +1,23 @@
# Build protobuf.
FROM golang:1.21-alpine AS protobuf
WORKDIR /protobuf-generate
COPY . .
RUN GO111MODULE=on GOBIN=/usr/local/bin go install github.com/bufbuild/buf/cmd/buf@v1.26.1
WORKDIR /protobuf-generate/proto
RUN buf generate
# Build frontend dist. # Build frontend dist.
FROM node:18-alpine AS frontend FROM node:18-alpine AS frontend
WORKDIR /frontend-build WORKDIR /frontend-build
COPY ./frontend . COPY . .
COPY --from=protobuf /protobuf-generate/frontend/web/src/types/proto ./web/src/types/proto WORKDIR /frontend-build/frontend/web
WORKDIR /frontend-build/web
RUN corepack enable && pnpm i --frozen-lockfile RUN corepack enable && pnpm i --frozen-lockfile
RUN pnpm build RUN pnpm build
# Build backend exec file. # Build backend exec file.
FROM golang:1.21-alpine AS backend FROM golang:1.22-alpine AS backend
WORKDIR /backend-build WORKDIR /backend-build
COPY . . COPY . .
COPY --from=frontend /frontend-build/web/dist ./server/dist COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
# Make workspace with above generated files. # Make workspace with above generated files.
FROM alpine:latest AS monolithic FROM alpine:latest AS monolithic

145
LICENSE
View File

@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@ -7,17 +7,15 @@
Preamble Preamble
The GNU General Public License is a free, copyleft license for The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works. software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the software for all its users.
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you Developers that use our General Public Licenses protect your rights
these rights or asking you to surrender the rights. Therefore, you have with two steps: (1) assert copyright on the software, and (2) offer
certain responsibilities if you distribute copies of the software, or if you this License which gives you legal permission to copy, distribute
you modify it: responsibilities to respect the freedom of others. and/or modify the software.
For example, if you distribute copies of such a program, whether A secondary benefit of defending all users' freedom is that
gratis or for a fee, you must pass on to the recipients the same improvements made in alternate versions of the program, if they
freedoms that you received. You must make sure that they, too, receive receive widespread use, become available for other developers to
or can get the source code. And you must show them these terms so they incorporate. Many developers of free software are heartened and
know their rights. encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps: The GNU Affero General Public License is designed specifically to
(1) assert copyright on the software, and (2) offer you this License ensure that, in such cases, the modified source code becomes available
giving you legal permission to copy, distribute and/or modify it. to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains An older license, called the Affero General Public License and
that there is no warranty for this free software. For both users' and published by Affero, was designed to accomplish similar goals. This is
authors' sake, the GPL requires that modified versions be marked as a different license, not a version of the Affero GPL, but Affero has
changed, so that their problems will not be attributed erroneously to released a new version of the Affero GPL which permits relicensing under
authors of previous versions. this license.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@ -72,7 +60,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU General Public License. "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License. 13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License, but the work with which it is combined will remain governed by version
section 13, concerning interaction through a network will apply to the 3 of the GNU General Public License.
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will the GNU Affero General Public License from time to time. Such new versions
be similar in spirit to the present version, but may differ in detail to will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@ -631,40 +629,33 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
<Slash>
Copyright (C) <2023> <Steven>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU Affero General Public License as published
the Free Software Foundation, either version 3 of the License, or by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If your software can interact with users remotely through a computer
notice like this when it starts in an interactive mode: network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. interface could display a "Source" link that leads users to an archive
This is free software, and you are welcome to redistribute it of the code. There are many ways you could offer source, and different
under certain conditions; type `show c' for details. solutions will be better for different programs; see section 13 for the
specific requirements.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -1,27 +1,33 @@
# Slash # Slash
<img align="right" src="./resources/logo.png" height="64px" alt="logo"> **Slash** is an open source, self-hosted links shortener and sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them using custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration. 🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a><a href="https://discord.gg/QZqUuUAhDV">Discord</a> Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
[👉 Join our Discord 💬](https://discord.gg/QZqUuUAhDV)
<p> <p>
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a> <a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github"/></a> <a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
</p> </p>
<p align="center"> ![demo](./docs/assets/demo.png)
<a href="https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg"><b>🧩 Browser extension now available!</b></a></p>
![demo](./resources/demo.png) ## Background
In today's workplace, essential information is often scattered across the cloud in the form of links. We understand the frustration of endlessly searching through emails, messages, and websites just to find the right link. Links are notorious for being unwieldy, complex, and easily lost in the shuffle. Remembering and sharing them can be a challenge.
That's why we developed Slash, a solution that transforms these links into easily accessible, discoverable, and shareable shortcuts(e.g., `s/shortcut`). Say goodbye to link chaos and welcome the organizational ease of Slash into your daily online workflow.
## Features ## Features
- Create customizable `/s/` short links for any URL. - Create customizable `s/` short links for any URL.
- Share short links public or only with your teammates. - Share short links public or only with your teammates.
- View analytics on link traffic and sources. - View analytics on link traffic and sources.
- Easy access to your shortcuts with browser extension. - Easy access to your shortcuts with browser extension.
- Share your shortcuts with Collection to anyone, on any browser.
- Open source self-hosted solution. - Open source self-hosted solution.
## Deploy with Docker in seconds ## Deploy with Docker in seconds
@ -30,16 +36,20 @@
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
``` ```
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md). Learn more in [Self-hosting Slash with Docker](https://github.com/yourselfhosted/slash/blob/main/docs/install.md).
## Browser Extension ## Browser Extension
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL. Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
![browser-extension-example](./resources/browser-extension-example.png) ![browser-extension-example](./docs/assets/browser-extension-example.png)
Learn more in [The Browser Extension of Slash](https://github.com/yourselfhosted/slash/blob/main/docs/install-browser-extension.md).
### Chromium based browsers ### Chromium based browsers
For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg). For Chromium based browsers(Chrome, Edge, Arc, ...), you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md). ### Firefox
For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/your-slash/).

View File

@ -1,12 +0,0 @@
package v1
type ActivityShorcutCreatePayload struct {
ShortcutID int32 `json:"shortcutId"`
}
type ActivityShorcutViewPayload struct {
ShortcutID int32 `json:"shortcutId"`
IP string `json:"ip"`
Referer string `json:"referer"`
UserAgent string `json:"userAgent"`
}

View File

@ -1,129 +0,0 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/mssola/useragent"
"golang.org/x/exp/slices"
"github.com/boojack/slash/store"
)
type ReferenceInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type DeviceInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type BrowserInfo struct {
Name string `json:"name"`
Count int `json:"count"`
}
type AnalysisData struct {
ReferenceData []ReferenceInfo `json:"referenceData"`
DeviceData []DeviceInfo `json:"deviceData"`
BrowserData []BrowserInfo `json:"browserData"`
}
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
Type: store.ActivityShortcutView,
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
}
referenceMap := make(map[string]int)
deviceMap := make(map[string]int)
browserMap := make(map[string]int)
for _, activity := range activities {
payload := &ActivityShorcutViewPayload{}
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
}
if _, ok := referenceMap[payload.Referer]; !ok {
referenceMap[payload.Referer] = 0
}
referenceMap[payload.Referer]++
ua := useragent.New(payload.UserAgent)
deviceName := ua.OSInfo().Name
browserName, _ := ua.Browser()
if _, ok := deviceMap[deviceName]; !ok {
deviceMap[deviceName] = 0
}
deviceMap[deviceName]++
if _, ok := browserMap[browserName]; !ok {
browserMap[browserName] = 0
}
browserMap[browserName]++
}
return c.JSON(http.StatusOK, &AnalysisData{
ReferenceData: mapToReferenceInfoSlice(referenceMap),
DeviceData: mapToDeviceInfoSlice(deviceMap),
BrowserData: mapToBrowserInfoSlice(browserMap),
})
})
}
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
referenceInfoSlice := make([]ReferenceInfo, 0)
for key, value := range m {
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
Name: key,
Count: value,
})
}
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
return i.Count > j.Count
})
return referenceInfoSlice
}
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
deviceInfoSlice := make([]DeviceInfo, 0)
for key, value := range m {
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
Name: key,
Count: value,
})
}
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
return i.Count > j.Count
})
return deviceInfoSlice
}
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
browserInfoSlice := make([]BrowserInfo, 0)
for key, value := range m {
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
Name: key,
Count: value,
})
}
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
return i.Count > j.Count
})
return browserInfoSlice
}

View File

@ -1,208 +0,0 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"github.com/boojack/slash/api/auth"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
type SignInRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type SignUpRequest struct {
Nickname string `json:"nickname"`
Email string `json:"email"`
Password string `json:"password"`
}
func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &SignInRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
Email: &signin.Email,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
} else if user.RowStatus == store.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
}
// Compare the stored hashed password, with the hashed version of the password that was received.
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
}
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
return c.JSON(http.StatusOK, convertUserFromStore(user))
})
g.POST("/auth/signup", func(c echo.Context) error {
ctx := c.Request().Context()
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
}
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
}
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
}
if len(userList) >= 5 {
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
}
}
signup := &SignUpRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
}
create := &store.User{
Email: signup.Email,
Nickname: signup.Nickname,
PasswordHash: string(passwordHash),
}
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
}
// The first user to sign up is an admin by default.
if len(existingUsers) == 0 {
create.Role = store.RoleAdmin
} else {
create.Role = store.RoleUser
}
user, err := s.Store.CreateUser(ctx, create)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
}
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
}
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
}
cookieExp := time.Now().Add(auth.CookieExpDuration)
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
return c.JSON(http.StatusOK, convertUserFromStore(user))
})
g.POST("/auth/logout", func(c echo.Context) error {
ctx := c.Request().Context()
RemoveTokensAndCookies(c)
accessToken := findAccessToken(c)
userID, _ := getUserIDFromAccessToken(accessToken, secret)
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
// Auto remove the current access token from the user access tokens.
if err == nil && len(userAccessTokens) != 0 {
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
for _, userAccessToken := range userAccessTokens {
if accessToken != userAccessToken.AccessToken {
accessTokens = append(accessTokens, userAccessToken)
}
}
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: userID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: accessTokens,
},
},
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
}
}
c.Response().WriteHeader(http.StatusOK)
return nil
})
}
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return errors.Wrap(err, "failed to get user access tokens")
}
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
AccessToken: accessToken,
Description: "Account sign in",
}
userAccessTokens = append(userAccessTokens, &userAccessToken)
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
Value: &storepb.UserSetting_AccessTokens{
AccessTokens: &storepb.AccessTokensUserSetting{
AccessTokens: userAccessTokens,
},
},
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
}
return nil
}
// RemoveTokensAndCookies removes the jwt token from the cookies.
func RemoveTokensAndCookies(c echo.Context) {
cookieExp := time.Now().Add(-1 * time.Hour)
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
}
// setTokenCookie sets the token to the cookie.
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
cookie := new(http.Cookie)
cookie.Name = name
cookie.Value = token
cookie.Expires = expiration
cookie.Path = "/"
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteStrictMode
c.SetCookie(cookie)
}

View File

@ -1,15 +0,0 @@
package v1
// RowStatus is the status for a row.
type RowStatus string
const (
// Normal is the status for a normal row.
Normal RowStatus = "NORMAL"
// Archived is the status for an archived row.
Archived RowStatus = "ARCHIVED"
)
func (s RowStatus) String() string {
return string(s)
}

View File

@ -1,133 +0,0 @@
package v1
import (
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/boojack/slash/api/auth"
"github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
)
const (
// The key name used to store user id in the context
// user id is extracted from the jwt token subject field.
userIDContextKey = "user-id"
)
func extractTokenFromHeader(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return "", nil
}
authHeaderParts := strings.Fields(authHeader)
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
return "", errors.New("Authorization header format must be Bearer {token}")
}
return authHeaderParts[1], nil
}
func findAccessToken(c echo.Context) string {
// Check the HTTP request header first.
accessToken, _ := extractTokenFromHeader(c)
if accessToken == "" {
// Check the cookie.
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
if cookie != nil {
accessToken = cookie.Value
}
}
return accessToken
}
// JWTMiddleware validates the access token.
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
path := c.Request().URL.Path
method := c.Request().Method
// Pass auth and profile endpoints.
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
return next(c)
}
accessToken := findAccessToken(c)
if accessToken == "" {
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
if util.HasPrefixes(path, "/s/") && method == http.MethodGet {
return next(c)
}
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
}
userID, err := getUserIDFromAccessToken(accessToken, secret)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
}
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
}
if !validateAccessToken(accessToken, accessTokens) {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
}
// Even if there is no error, we still need to make sure the user still exists.
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
}
// Stores userID into context.
c.Set(userIDContextKey, userID)
return next(c)
}
}
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
claims := &auth.ClaimsMessage{}
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == "v1" {
return []byte(secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return 0, errors.Wrap(err, "Invalid or expired access token")
}
// We either have a valid access token or we will attempt to generate new access token.
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return 0, errors.Wrap(err, "Malformed ID in the token")
}
return userID, nil
}
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
for _, userAccessToken := range userAccessTokens {
if accessTokenString == userAccessToken.AccessToken {
return true
}
}
return false
}

View File

@ -1,116 +0,0 @@
package v1
import (
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
)
func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
g.GET("/*", func(c echo.Context) error {
ctx := c.Request().Context()
if len(c.ParamValues()) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "invalid shortcut name")
}
shortcutName := c.ParamValues()[0]
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &shortcutName,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get shortcut, err: %s", err)).SetInternal(err)
}
if shortcut == nil {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/404?shortcut=%s", shortcutName))
}
if shortcut.Visibility != storepb.Visibility_PUBLIC {
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
}
if err := s.createShortcutViewActivity(c, shortcut); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
}
return redirectToShortcut(c, shortcut)
})
}
func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
isValidURL := isValidURLString(shortcut.Link)
if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
if isValidURL {
return c.Redirect(http.StatusSeeOther, shortcut.Link)
}
return c.String(http.StatusOK, shortcut.Link)
}
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
metadataList := []string{
fmt.Sprintf(`<title>%s</title>`, shortcut.OgMetadata.Title),
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OgMetadata.Description),
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OgMetadata.Title),
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OgMetadata.Description),
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OgMetadata.Image),
`<meta property="og:type" content="website" />`,
// Twitter related metadata.
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OgMetadata.Title),
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OgMetadata.Description),
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OgMetadata.Image),
`<meta name="twitter:card" content="summary_large_image" />`,
}
if isValidURL {
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
}
body := ""
if isValidURL {
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
} else {
body = html.EscapeString(shortcut.Link)
}
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
return c.HTML(http.StatusOK, htmlString)
}
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
payload := &ActivityShorcutViewPayload{
ShortcutID: shortcut.Id,
IP: c.RealIP(),
Referer: c.Request().Referer(),
UserAgent: c.Request().UserAgent(),
}
payloadStr, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "Failed to marshal activity payload")
}
activity := &store.Activity{
CreatorID: BotID,
Type: store.ActivityShortcutView,
Level: store.ActivityInfo,
Payload: string(payloadStr),
}
_, err = s.Store.CreateActivity(c.Request().Context(), activity)
if err != nil {
return errors.Wrap(err, "Failed to create activity")
}
return nil
}
func isValidURLString(s string) bool {
_, err := url.ParseRequestURI(s)
return err == nil
}

View File

@ -1,33 +0,0 @@
package v1
import "testing"
func TestIsValidURLString(t *testing.T) {
tests := []struct {
link string
expected bool
}{
{
link: "https://google.com",
expected: true,
},
{
link: "http://google.com",
expected: true,
},
{
link: "google.com",
expected: false,
},
{
link: "mailto:email@example.com",
expected: true,
},
}
for _, test := range tests {
if isValidURLString(test.link) != test.expected {
t.Errorf("isValidURLString(%s) = %v, expected %v", test.link, !test.expected, test.expected)
}
}
}

View File

@ -1,381 +0,0 @@
package v1
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"github.com/boojack/slash/internal/util"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
)
// Visibility is the type of a shortcut visibility.
type Visibility string
const (
// VisibilityPublic is the PUBLIC visibility.
VisibilityPublic Visibility = "PUBLIC"
// VisibilityWorkspace is the WORKSPACE visibility.
VisibilityWorkspace Visibility = "WORKSPACE"
// VisibilityPrivate is the PRIVATE visibility.
VisibilityPrivate Visibility = "PRIVATE"
)
func (v Visibility) String() string {
return string(v)
}
type OpenGraphMetadata struct {
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
}
type Shortcut struct {
ID int32 `json:"id"`
// Standard fields
CreatorID int32 `json:"creatorId"`
Creator *User `json:"creator"`
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
RowStatus RowStatus `json:"rowStatus"`
// Domain specific fields
Name string `json:"name"`
Link string `json:"link"`
Title string `json:"title"`
Description string `json:"description"`
Visibility Visibility `json:"visibility"`
Tags []string `json:"tags"`
View int `json:"view"`
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
}
type CreateShortcutRequest struct {
Name string `json:"name"`
Link string `json:"link"`
Title string `json:"title"`
Description string `json:"description"`
Visibility Visibility `json:"visibility"`
Tags []string `json:"tags"`
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
}
type PatchShortcutRequest struct {
RowStatus *RowStatus `json:"rowStatus"`
Name *string `json:"name"`
Link *string `json:"link"`
Title *string `json:"title"`
Description *string `json:"description"`
Visibility *Visibility `json:"visibility"`
Tags []string `json:"tags"`
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
}
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
create := &CreateShortcutRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
}
shortcut := &storepb.Shortcut{
CreatorId: userID,
Name: create.Name,
Link: create.Link,
Title: create.Title,
Description: create.Description,
Visibility: convertVisibilityToStorepb(create.Visibility),
Tags: create.Tags,
OgMetadata: &storepb.OpenGraphMetadata{},
}
if create.OpenGraphMetadata != nil {
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
Title: create.OpenGraphMetadata.Title,
Description: create.OpenGraphMetadata.Description,
Image: create.OpenGraphMetadata.Image,
}
}
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
}
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
}
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, shortcutMessage)
})
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
}
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
}
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
ID: &shortcutID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
}
if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
}
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
}
patch := &PatchShortcutRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
}
shortcutUpdate := &store.UpdateShortcut{
ID: shortcutID,
Name: patch.Name,
Link: patch.Link,
Title: patch.Title,
Description: patch.Description,
}
if patch.RowStatus != nil {
shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
}
if patch.Visibility != nil {
shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
}
if patch.Tags != nil {
tag := strings.Join(patch.Tags, " ")
shortcutUpdate.Tag = &tag
}
if patch.OpenGraphMetadata != nil {
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
Title: patch.OpenGraphMetadata.Title,
Description: patch.OpenGraphMetadata.Description,
Image: patch.OpenGraphMetadata.Image,
}
}
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
}
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, shortcutMessage)
})
g.GET("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
find := &store.FindShortcut{}
if tag := c.QueryParam("tag"); tag != "" {
find.Tag = &tag
}
list := []*storepb.Shortcut{}
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
}
list = append(list, visibleShortcutList...)
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
find.CreatorID = &userID
privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
}
list = append(list, privateShortcutList...)
shortcutMessageList := []*Shortcut{}
for _, shortcut := range list {
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
}
shortcutMessageList = append(shortcutMessageList, shortcutMessage)
}
return c.JSON(http.StatusOK, shortcutMessageList)
})
g.GET("/shortcut/:id", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
}
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
ID: &shortcutID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
}
if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
}
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, shortcutMessage)
})
g.DELETE("/shortcut/:id", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
}
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user, err: %s", err)).SetInternal(err)
}
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
ID: &shortcutID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
}
if shortcut == nil {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
}
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
}
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
if shortcut == nil {
return nil, nil
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &shortcut.CreatorID,
})
if err != nil {
return nil, errors.Wrap(err, "Failed to get creator")
}
if user == nil {
return nil, errors.New("Creator not found")
}
shortcut.Creator = convertUserFromStore(user)
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
Type: store.ActivityShortcutView,
Level: store.ActivityInfo,
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
})
if err != nil {
return nil, errors.Wrap(err, "Failed to list activities")
}
shortcut.View = len(activityList)
return shortcut, nil
}
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
return &Shortcut{
ID: shortcut.Id,
CreatedTs: shortcut.CreatedTs,
UpdatedTs: shortcut.UpdatedTs,
CreatorID: shortcut.CreatorId,
RowStatus: RowStatus(shortcut.RowStatus.String()),
Name: shortcut.Name,
Link: shortcut.Link,
Title: shortcut.Title,
Description: shortcut.Description,
Visibility: Visibility(shortcut.Visibility.String()),
Tags: shortcut.Tags,
OpenGraphMetadata: &OpenGraphMetadata{
Title: shortcut.OgMetadata.Title,
Description: shortcut.OgMetadata.Description,
Image: shortcut.OgMetadata.Image,
},
}
}
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
switch visibility {
case VisibilityPublic:
return storepb.Visibility_PUBLIC
case VisibilityWorkspace:
return storepb.Visibility_WORKSPACE
case VisibilityPrivate:
return storepb.Visibility_PRIVATE
default:
return storepb.Visibility_PUBLIC
}
}
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
payload := &ActivityShorcutCreatePayload{
ShortcutID: shortcut.Id,
}
payloadStr, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "Failed to marshal activity payload")
}
activity := &store.Activity{
CreatorID: shortcut.CreatorId,
Type: store.ActivityShortcutCreate,
Level: store.ActivityInfo,
Payload: string(payloadStr),
}
_, err = s.Store.CreateActivity(ctx, activity)
if err != nil {
return errors.Wrap(err, "Failed to create activity")
}
return nil
}

View File

@ -1,333 +0,0 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"net/mail"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"github.com/boojack/slash/internal/util"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
const (
// BotID is the id of bot.
BotID = 0
)
// Role is the type of a role.
type Role string
const (
// RoleAdmin is the ADMIN role.
RoleAdmin Role = "ADMIN"
// RoleUser is the USER role.
RoleUser Role = "USER"
)
func (r Role) String() string {
switch r {
case RoleAdmin:
return "ADMIN"
case RoleUser:
return "USER"
}
return "USER"
}
type User struct {
ID int32 `json:"id"`
// Standard fields
CreatedTs int64 `json:"createdTs"`
UpdatedTs int64 `json:"updatedTs"`
RowStatus RowStatus `json:"rowStatus"`
// Domain specific fields
Email string `json:"email"`
Nickname string `json:"nickname"`
Role Role `json:"role"`
}
type CreateUserRequest struct {
Email string `json:"email"`
Nickname string `json:"nickname"`
Password string `json:"password"`
Role Role `json:"role"`
}
func (create CreateUserRequest) Validate() error {
if create.Email != "" && !validateEmail(create.Email) {
return errors.New("invalid email format")
}
if create.Nickname != "" && len(create.Nickname) < 3 {
return errors.New("nickname is too short, minimum length is 3")
}
if len(create.Password) < 3 {
return errors.New("password is too short, minimum length is 3")
}
return nil
}
type PatchUserRequest struct {
RowStatus *RowStatus `json:"rowStatus"`
Email *string `json:"email"`
Nickname *string `json:"nickname"`
Password *string `json:"password"`
Role *Role `json:"role"`
}
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
g.POST("/user", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
if currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
}
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
}
if len(userList) >= 5 {
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
}
}
userCreate := &CreateUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
user, err := s.Store.CreateUser(ctx, &store.User{
Role: store.Role(userCreate.Role),
Email: userCreate.Email,
Nickname: userCreate.Nickname,
PasswordHash: string(passwordHash),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
userMessage := convertUserFromStore(user)
return c.JSON(http.StatusOK, userMessage)
})
g.GET("/user", func(c echo.Context) error {
ctx := c.Request().Context()
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
}
userList := []*User{}
for _, user := range list {
userList = append(userList, convertUserFromStore(user))
}
return c.JSON(http.StatusOK, userList)
})
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, convertUserFromStore(user))
})
g.GET("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
userID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, convertUserFromStore(user))
})
g.PATCH("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
userID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
}
currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
}
userPatch := &PatchUserRequest{}
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
}
updateUser := &store.UpdateUser{
ID: userID,
}
if userPatch.Email != nil {
if !validateEmail(*userPatch.Email) {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
}
updateUser.Email = userPatch.Email
}
if userPatch.Nickname != nil {
updateUser.Nickname = userPatch.Nickname
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
}
passwordHashStr := string(passwordHash)
updateUser.PasswordHash = &passwordHashStr
}
if userPatch.RowStatus != nil {
rowStatus := store.RowStatus(*userPatch.RowStatus)
updateUser.RowStatus = &rowStatus
}
if userPatch.Role != nil {
adminRole := store.RoleAdmin
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
Role: &adminRole,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
}
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
}
role := store.Role(*userPatch.Role)
updateUser.Role = &role
}
user, err := s.Store.UpdateUser(ctx, updateUser)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, convertUserFromStore(user))
})
g.DELETE("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
currentUserID, ok := c.Get(userIDContextKey).(int32)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
}
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
}
if currentUser.Role != store.RoleAdmin {
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
}
userID, err := util.ConvertStringToInt32(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
}
if user.Role == store.RoleAdmin {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
}
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: userID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
}
return c.JSON(http.StatusOK, true)
})
}
// validateEmail validates the email.
func validateEmail(email string) bool {
if _, err := mail.ParseAddress(email); err != nil {
return false
}
return true
}
// convertUserFromStore converts a store user to a user.
func convertUserFromStore(user *store.User) *User {
return &User{
ID: user.ID,
CreatedTs: user.CreatedTs,
UpdatedTs: user.UpdatedTs,
RowStatus: RowStatus(user.RowStatus),
Email: user.Email,
Nickname: user.Nickname,
Role: Role(user.Role),
}
}

View File

@ -1,67 +0,0 @@
package v1
import (
"encoding/json"
"github.com/pkg/errors"
)
type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
)
// String returns the string format of UserSettingKey type.
func (k UserSettingKey) String() string {
return string(k)
}
var (
UserSettingLocaleValue = []string{"en", "zh"}
)
type UserSetting struct {
UserID int
Key UserSettingKey `json:"key"`
// Value is a JSON string with basic value.
Value string `json:"value"`
}
type UserSettingUpsert struct {
UserID int
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
func (upsert UserSettingUpsert) Validate() error {
if upsert.Key == UserSettingLocaleKey {
localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil {
return errors.New("failed to unmarshal user setting locale value")
}
invalid := true
for _, value := range UserSettingLocaleValue {
if localeValue == value {
invalid = false
break
}
}
if invalid {
return errors.New("invalid user setting locale value")
}
} else {
return errors.New("invalid user setting key")
}
return nil
}
type UserSettingFind struct {
UserID int
Key *UserSettingKey `json:"key"`
}

View File

@ -1,41 +0,0 @@
package v1
import (
"github.com/labstack/echo/v4"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
type APIV1Service struct {
Profile *profile.Profile
Store *store.Store
LicenseService *license.LicenseService
}
func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
return &APIV1Service{
Profile: profile,
Store: store,
LicenseService: licenseService,
}
}
func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
apiV1Group := apiGroup.Group("/api/v1")
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, secret)
})
s.registerWorkspaceRoutes(apiV1Group)
s.registerAuthRoutes(apiV1Group, secret)
s.registerUserRoutes(apiV1Group)
s.registerShortcutRoutes(apiV1Group)
s.registerAnalyticsRoutes(apiV1Group)
redirectorGroup := apiGroup.Group("/s")
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return JWTMiddleware(s, next, secret)
})
s.registerRedirectorRoutes(redirectorGroup)
}

View File

@ -1,39 +0,0 @@
package v1
import (
"fmt"
"net/http"
"github.com/labstack/echo/v4"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/store"
)
type WorkspaceProfile struct {
Profile *profile.Profile `json:"profile"`
DisallowSignUp bool `json:"disallowSignUp"`
}
func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
g.GET("/workspace/profile", func(c echo.Context) error {
ctx := c.Request().Context()
workspaceProfile := WorkspaceProfile{
Profile: s.Profile,
DisallowSignUp: false,
}
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
}
if enableSignUpSetting != nil {
workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
}
return c.JSON(http.StatusOK, workspaceProfile)
})
}

View File

@ -1,27 +0,0 @@
package v2
import "strings"
var allowedMethodsWhenUnauthorized = map[string]bool{
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
}
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
func isUnauthorizeAllowedMethod(methodName string) bool {
if strings.HasPrefix(methodName, "/grpc.reflection") {
return true
}
return allowedMethodsWhenUnauthorized[methodName]
}
var allowedMethodsOnlyForAdmin = map[string]bool{
"/slash.api.v2.UserService/CreateUser": true,
"/slash.api.v2.UserService/DeleteUser": true,
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
}
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
func isOnlyForAdminAllowedMethod(methodName string) bool {
return allowedMethodsOnlyForAdmin[methodName]
}

View File

@ -1,17 +0,0 @@
package v2
import (
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
"github.com/boojack/slash/store"
)
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
switch rowStatus {
case store.Normal:
return apiv2pb.RowStatus_NORMAL
case store.Archived:
return apiv2pb.RowStatus_ARCHIVED
default:
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
}
}

View File

@ -1,185 +0,0 @@
package v2
import (
"context"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
storepb "github.com/boojack/slash/proto/gen/store"
"github.com/boojack/slash/store"
)
type ShortcutService struct {
apiv2pb.UnimplementedShortcutServiceServer
Secret string
Store *store.Store
}
// NewShortcutService creates a new Shortcut service.
func NewShortcutService(secret string, store *store.Store) *ShortcutService {
return &ShortcutService{
Secret: secret,
Store: store,
}
}
func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
find := &store.FindShortcut{}
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to fetch visible shortcut list, err: %v", err)
}
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
find.CreatorID = &userID
shortcutList, err := s.Store.ListShortcuts(ctx, find)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to fetch private shortcut list, err: %v", err)
}
shortcutList = append(shortcutList, visibleShortcutList...)
shortcuts := []*apiv2pb.Shortcut{}
for _, shortcut := range shortcutList {
shortcuts = append(shortcuts, convertShortcutFromStorepb(shortcut))
}
response := &apiv2pb.ListShortcutsResponse{
Shortcuts: shortcuts,
}
return response, nil
}
func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &request.Name,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
}
if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found")
}
userID := ctx.Value(userIDContextKey).(int32)
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
shortcutMessage := convertShortcutFromStorepb(shortcut)
response := &apiv2pb.GetShortcutResponse{
Shortcut: shortcutMessage,
}
return response, nil
}
func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
shortcut := &storepb.Shortcut{
CreatorId: userID,
Name: request.Shortcut.Name,
Link: request.Shortcut.Link,
Title: request.Shortcut.Title,
Tags: request.Shortcut.Tags,
Description: request.Shortcut.Description,
Visibility: storepb.Visibility(request.Shortcut.Visibility),
OgMetadata: &storepb.OpenGraphMetadata{},
}
if request.Shortcut.OgMetadata != nil {
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
Title: request.Shortcut.OgMetadata.Title,
Description: request.Shortcut.OgMetadata.Description,
Image: request.Shortcut.OgMetadata.Image,
}
}
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create shortcut, err: %v", err)
}
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
}
response := &apiv2pb.CreateShortcutResponse{
Shortcut: convertShortcutFromStorepb(shortcut),
}
return response, nil
}
func (s *ShortcutService) DeleteShortcut(ctx context.Context, request *apiv2pb.DeleteShortcutRequest) (*apiv2pb.DeleteShortcutResponse, error) {
userID := ctx.Value(userIDContextKey).(int32)
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
}
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &request.Name,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
}
if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found")
}
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
}
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
ID: shortcut.Id,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete shortcut, err: %v", err)
}
response := &apiv2pb.DeleteShortcutResponse{}
return response, nil
}
func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
payload := &storepb.ActivityShorcutCreatePayload{
ShortcutId: shortcut.Id,
}
payloadStr, err := protojson.Marshal(payload)
if err != nil {
return errors.Wrap(err, "Failed to marshal activity payload")
}
activity := &store.Activity{
CreatorID: shortcut.CreatorId,
Type: store.ActivityShortcutCreate,
Level: store.ActivityInfo,
Payload: string(payloadStr),
}
_, err = s.Store.CreateActivity(ctx, activity)
if err != nil {
return errors.Wrap(err, "Failed to create activity")
}
return nil
}
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
return &apiv2pb.Shortcut{
Id: shortcut.Id,
CreatorId: shortcut.CreatorId,
CreatedTs: shortcut.CreatedTs,
UpdatedTs: shortcut.UpdatedTs,
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
Name: shortcut.Name,
Link: shortcut.Link,
Title: shortcut.Title,
Tags: shortcut.Tags,
Description: shortcut.Description,
Visibility: apiv2pb.Visibility(shortcut.Visibility),
OgMetadata: &apiv2pb.OpenGraphMetadata{
Title: shortcut.OgMetadata.Title,
Description: shortcut.OgMetadata.Description,
Image: shortcut.OgMetadata.Image,
},
}
}

View File

@ -1,50 +0,0 @@
package v2
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
type SubscriptionService struct {
apiv2pb.UnimplementedSubscriptionServiceServer
Profile *profile.Profile
Store *store.Store
LicenseService *license.LicenseService
}
// NewSubscriptionService creates a new SubscriptionService.
func NewSubscriptionService(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *SubscriptionService {
return &SubscriptionService{
Profile: profile,
Store: store,
LicenseService: licenseService,
}
}
func (s *SubscriptionService) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {
subscription, err := s.LicenseService.LoadSubscription(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
}
return &apiv2pb.GetSubscriptionResponse{
Subscription: subscription,
}, nil
}
func (s *SubscriptionService) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
}
return &apiv2pb.UpdateSubscriptionResponse{
Subscription: subscription,
}, nil
}

View File

@ -1,100 +0,0 @@
package v2
import (
"context"
"fmt"
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/improbable-eng/grpc-web/go/grpcweb"
"github.com/labstack/echo/v4"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/reflection"
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
"github.com/boojack/slash/server/profile"
"github.com/boojack/slash/server/service/license"
"github.com/boojack/slash/store"
)
type APIV2Service struct {
Secret string
Profile *profile.Profile
Store *store.Store
LicenseService *license.LicenseService
grpcServer *grpc.Server
grpcServerPort int
}
func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store, licenseService *license.LicenseService, grpcServerPort int) *APIV2Service {
authProvider := NewGRPCAuthInterceptor(store, secret)
grpcServer := grpc.NewServer(
grpc.ChainUnaryInterceptor(
authProvider.AuthenticationInterceptor,
),
)
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, NewSubscriptionService(profile, store, licenseService))
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, NewWorkspaceService(profile, store, licenseService))
apiv2pb.RegisterUserServiceServer(grpcServer, NewUserService(secret, store, licenseService))
apiv2pb.RegisterUserSettingServiceServer(grpcServer, NewUserSettingService(store))
apiv2pb.RegisterShortcutServiceServer(grpcServer, NewShortcutService(secret, store))
reflection.Register(grpcServer)
return &APIV2Service{
Secret: secret,
Profile: profile,
Store: store,
LicenseService: licenseService,
grpcServer: grpcServer,
grpcServerPort: grpcServerPort,
}
}
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
return s.grpcServer
}
// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error {
// Create a client connection to the gRPC Server we just started.
// This is where the gRPC-Gateway proxies the requests.
conn, err := grpc.DialContext(
ctx,
fmt.Sprintf(":%d", s.grpcServerPort),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return err
}
gwMux := grpcRuntime.NewServeMux()
if err := apiv2pb.RegisterSubscriptionServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterWorkspaceServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterUserSettingServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
return err
}
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
// GRPC web proxy.
options := []grpcweb.Option{
grpcweb.WithCorsForRegisteredEndpointsOnly(false),
grpcweb.WithOriginFunc(func(origin string) bool {
return true
}),
}
wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
e.Any("/slash.api.v2.*", echo.WrapHandler(wrappedGrpc))
return nil
}

View File

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -10,14 +11,12 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/zap"
_ "modernc.org/sqlite"
"github.com/boojack/slash/internal/log" "github.com/yourselfhosted/slash/server"
"github.com/boojack/slash/server" "github.com/yourselfhosted/slash/server/metric"
"github.com/boojack/slash/server/profile" "github.com/yourselfhosted/slash/server/profile"
"github.com/boojack/slash/store" "github.com/yourselfhosted/slash/store"
"github.com/boojack/slash/store/db" "github.com/yourselfhosted/slash/store/db"
) )
const ( const (
@ -29,27 +28,40 @@ var (
mode string mode string
port int port int
data string data string
driver string
dsn string
enableMetric bool
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "slash", Use: "slash",
Short: `An open source, self-hosted bookmarks and link sharing platform.`, Short: `An open source, self-hosted links shortener and sharing platform.`,
Run: func(_cmd *cobra.Command, _args []string) { Run: func(_ *cobra.Command, _ []string) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
db := db.NewDB(serverProfile) dbDriver, err := db.NewDBDriver(serverProfile)
if err := db.Open(ctx); err != nil { if err != nil {
cancel() cancel()
log.Error("failed to open database", zap.Error(err)) slog.Error("failed to create db driver", err)
return
}
if err := dbDriver.Migrate(ctx); err != nil {
cancel()
slog.Error("failed to migrate db", err)
return return
} }
storeInstance := store.New(db.DBInstance, serverProfile) storeInstance := store.New(dbDriver, serverProfile)
s, err := server.NewServer(ctx, serverProfile, storeInstance) s, err := server.NewServer(ctx, serverProfile, storeInstance)
if err != nil { if err != nil {
cancel() cancel()
log.Error("failed to create server", zap.Error(err)) slog.Error("failed to create server", err)
return return
} }
if serverProfile.Metric {
// nolint
metric.NewMetricClient(s.Secret, *serverProfile)
}
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
// Trigger graceful shutdown on SIGINT or SIGTERM. // Trigger graceful shutdown on SIGINT or SIGTERM.
// The default signal sent by the `kill` command is SIGTERM, // The default signal sent by the `kill` command is SIGTERM,
@ -57,7 +69,7 @@ var (
signal.Notify(c, os.Interrupt, syscall.SIGTERM) signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() { go func() {
sig := <-c sig := <-c
log.Info(fmt.Sprintf("%s received.\n", sig.String())) slog.Info(fmt.Sprintf("%s received.\n", sig.String()))
s.Shutdown(ctx) s.Shutdown(ctx)
cancel() cancel()
}() }()
@ -66,7 +78,7 @@ var (
if err := s.Start(ctx); err != nil { if err := s.Start(ctx); err != nil {
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
log.Error("failed to start server", zap.Error(err)) slog.Error("failed to start server", err)
cancel() cancel()
} }
} }
@ -78,7 +90,6 @@ var (
) )
func Execute() error { func Execute() error {
defer log.Sync()
return rootCmd.Execute() return rootCmd.Execute()
} }
@ -88,6 +99,9 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`) rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server") rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory") rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
rootCmd.PersistentFlags().StringVarP(&driver, "driver", "", "", "database driver")
rootCmd.PersistentFlags().StringVarP(&dsn, "dsn", "", "", "database source name(aka. DSN)")
rootCmd.PersistentFlags().BoolVarP(&enableMetric, "metric", "", true, "allow metric collection")
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")) err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
if err != nil { if err != nil {
@ -101,9 +115,23 @@ func init() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
err = viper.BindPFlag("driver", rootCmd.PersistentFlags().Lookup("driver"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn"))
if err != nil {
panic(err)
}
err = viper.BindPFlag("metric", rootCmd.PersistentFlags().Lookup("metric"))
if err != nil {
panic(err)
}
viper.SetDefault("mode", "demo") viper.SetDefault("mode", "demo")
viper.SetDefault("port", 8082) viper.SetDefault("port", 8082)
viper.SetDefault("driver", "sqlite")
viper.SetDefault("metric", true)
viper.SetEnvPrefix("slash") viper.SetEnvPrefix("slash")
} }
@ -112,7 +140,7 @@ func initConfig() {
var err error var err error
serverProfile, err = profile.GetProfile() serverProfile, err = profile.GetProfile()
if err != nil { if err != nil {
log.Error("failed to get profile", zap.Error(err)) slog.Error("failed to get profile", err)
return return
} }
@ -130,7 +158,7 @@ func printGreetings() {
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port) fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
println("---") println("---")
println("See more in:") println("See more in:")
fmt.Printf("👉GitHub: %s\n", "https://github.com/boojack/slash") fmt.Printf("👉GitHub: %s\n", "https://github.com/yourselfhosted/slash")
println("---") println("---")
} }

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/assets/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

View File

@ -0,0 +1,43 @@
# Slash Collections
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts.
## What is a Collection?
A Collection is like a virtual folder where you can group and organize your related Shortcuts. It acts as a container that holds Shortcuts together for a specific purpose or theme. Let's break down the key attributes:
- **Name:** Your chosen label for the Collection. This becomes a crucial part of the URL, enabling direct and quick access to the Collection. For example, if your Collection is named "work-projects", the direct access link would be `c/work-projects`. This user-defined name significantly enhances the accessibility and recognition of your Collections.
- **Title:** A brief title summarizing the Collection's content.
- **Description:** A short description explaining what the Collection is about.
- **Shortcuts:** The Shortcuts included in the Collection.
- **Visibility:** Settings to control who can access the Collection.
## What Problems Does It Solve?
Slash Collections tackle the challenge of efficiently managing and organizing related Shortcuts. By grouping Shortcuts into Collections, you can create a more structured and accessible workflow. This makes it easier to find, access, and share information based on specific themes or projects.
## How to Use Collections
### Creating a Collection
1. **Define the Collection:** Give your Collection a meaningful name and a descriptive title.
2. **Add Details:** Provide a brief description of the content within the Collection.
3. **Add Shortcuts:** Include relevant Shortcuts by selecting them from your existing list.
4. **Set Visibility:** Choose who should have access to the Collection.
5. **Save:** Once saved, your Collection is ready to use.
### Accessing Collections
Access a Collection directly by using the assigned name. For example, if your Collection is named "work-projects", the direct access link would be `{YOUR_DOMAIN}/c/work-projects`.
### Updating and Managing Collections
Modify Collection details, such as name, title, or included Shortcuts, to keep your organization streamlined and relevant.
### Sharing Collections
Share Collections by providing the assigned name to collaborators for easy access to grouped Shortcuts.
## Conclusion
Slash Collections offer a user-friendly and organized way to group, manage, and share related Shortcuts. By utilizing the defined Collection attributes, users can seamlessly categorize and access information, promoting collaboration and improving overall productivity.

View File

@ -0,0 +1,47 @@
# Slash Shortcuts
**Slash Shortcuts** is a handy tool designed to make handling and sharing links in your digital workspace a breeze.
## What is a Shortcut?
A Shortcut is a simplified version of a link with essential details, making it easy to remember, organize, and share. Let's break down the key elements:
- **Name:** Your chosen label for the Shortcut. This becomes a crucial part of the URL, enabling direct and quick access to the Shortcut. For example, if your Shortcut is named "meet-john", the direct access link would be `s/meet-john`. This user-defined name significantly enhances the accessibility and recognition of your Shortcuts.
- **Link:** The original web link you want to streamline.
- **Title:** A quick overview of what's behind the link.
- **Tags:** Custom labels for easy sorting.
- **Description:** A short summary of the content.
- **Visibility:** Controls who can access the Shortcut.
## How to Use Shortcuts
### Creating a Shortcut
1. **Define the Link:** Paste the original link you want to simplify.
2. **Add Details:** Give it a name, tags, and a brief description for better organization.
3. **Set Visibility:** Choose who should be able to access the Shortcut.
4. **Save:** Once saved, your Shortcut is ready to go.
### Accessing Shortcuts
#### Direct Access
Effortlessly access your Shortcut's content directly by using the assigned name as part of the Slash Shortcuts format.
For example, if your Shortcut is named "meet-john", the direct access link would be `{YOUR_DOMAIN}/s/meet-john`. Simply enter this user-friendly shortcut into your browser to reach the associated content with ease.
#### Browser Extension Access
Install the Slash Shortcuts browser extension for even quicker access. Once installed, simply type `s/meet-john` into your browser's address bar, and the extension will seamlessly redirect you to the corresponding page.
### Updating and Managing Shortcuts
Adjust attributes like name and tags to update a Shortcut. Keep your Shortcuts organized based on categories and visibility settings.
### Sharing Shortcuts
Share Shortcuts by providing the assigned name to collaborators for easy access.
## Conclusion
Shortcuts provide a simple way to manage, organize, and share links within your digital workspace. By using the defined Shortcut attributes, users can easily create, access, and share information, promoting collaboration and boosting productivity.

View File

@ -8,7 +8,7 @@ Slash provides a browser extension to help you use your shortcuts in the search
For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg). For Chromuim based browsers, you can install the extension from the [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg).
For Firefox, we don't support the Firefox Add-ons platform yet. And we are working on it. For Firefox, you can install the extension from the [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/your-slash/).
### Generate an access token ### Generate an access token

View File

@ -44,7 +44,7 @@ Assume that docker compose is deployed in the `/opt/slash` directory.
```bash ```bash
mkdir -p /opt/slash && cd /opt/slash mkdir -p /opt/slash && cd /opt/slash
curl -#LO https://github.com/boojack/slash/raw/main/docker-compose.yml curl -#LO https://github.com/yourselfhosted/slash/raw/main/docker-compose.yml
docker compose up -d docker compose up -d
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 852 B

View File

@ -1,58 +1,61 @@
{ {
"name": "slash-extension", "name": "slash-extension",
"displayName": "Slash", "displayName": "Slash",
"version": "1.0.0", "version": "1.0.8",
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.", "description": "An open source, self-hosted links shortener and sharing platform. Save and share your links very easily.",
"scripts": { "scripts": {
"dev": "plasmo dev", "dev": "plasmo dev",
"build": "plasmo build", "build": "plasmo build",
"package": "plasmo package", "package": "plasmo package",
"lint": "eslint --ext .js,.ts,.tsx, src", "lint": "eslint --ext .js,.ts,.tsx, src",
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix" "lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
"postinstall": "cd ../../proto && buf generate"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.5",
"@mui/joy": "5.0.0-beta.0", "@mui/joy": "5.0.0-beta.32",
"@plasmohq/storage": "^1.8.0", "@plasmohq/storage": "^1.10.0",
"axios": "^1.5.1", "axios": "^1.6.8",
"classnames": "^2.3.2", "classnames": "^2.5.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.264.0", "lucide-react": "^0.312.0",
"plasmo": "0.82.0", "plasmo": "^0.85.2",
"react": "18.2.0", "react": "^18.2.0",
"react-dom": "18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"zustand": "^4.4.1" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@trivago/prettier-plugin-sort-imports": "4.1.0", "@bufbuild/buf": "^1.30.1",
"@types/chrome": "0.0.241", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/lodash-es": "^4.17.9", "@types/chrome": "^0.0.266",
"@types/node": "20.4.2", "@types/lodash-es": "^4.17.12",
"@types/react": "18.2.15", "@types/node": "^20.12.7",
"@types/react-dom": "18.2.7", "@types/react": "^18.2.79",
"@typescript-eslint/eslint-plugin": "^6.7.3", "@types/react-dom": "^18.2.25",
"@typescript-eslint/parser": "^6.7.3", "@typescript-eslint/eslint-plugin": "^6.21.0",
"autoprefixer": "^10.4.16", "@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.50.0", "autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.34.1",
"long": "^5.2.3", "long": "^5.2.3",
"postcss": "^8.4.31", "postcss": "^8.4.38",
"prettier": "2.6.2", "prettier": "^2.8.8",
"protobufjs": "^7.2.5", "protobufjs": "^7.2.6",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.4.3",
"typescript": "5.1.6" "typescript": "^5.4.5"
}, },
"manifest": { "manifest": {
"omnibox": {
"keyword": "s"
},
"permissions": [ "permissions": [
"tabs", "activeTab",
"storage" "storage",
"webRequest"
],
"host_permissions": [
"*://*/*"
] ]
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,24 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { Storage } from "@plasmohq/storage"; import { Storage } from "@plasmohq/storage";
const storage = new Storage(); const storage = new Storage();
const urlRegex = /https?:\/\/s\/(.+)/; const urlRegex = /https?:\/\/s\/(.+)/;
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => { chrome.webRequest.onBeforeRequest.addListener(
if (!tab.url) { (param) => {
return; (async () => {
} if (!param.url) {
return;
}
const shortcutName = getShortcutNameFromUrl(tab.url); const shortcutName = getShortcutNameFromUrl(param.url);
if (shortcutName) { if (shortcutName) {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || []; const instanceUrl = (await storage.getItem<string>("domain")) || "";
const shortcut = shortcuts.find((shortcut) => shortcut.name === shortcutName); return chrome.tabs.update({ url: `${instanceUrl}/s/${shortcutName}` });
if (!shortcut) { }
return; })();
} },
return chrome.tabs.update(tabId, { url: shortcut.link }); { urls: ["*://s/*", "*://*/search*"] }
} );
});
chrome.omnibox.onInputEntered.addListener(async (text) => {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
if (!shortcut) {
return;
}
return chrome.tabs.update({ url: shortcut.link });
});
const getShortcutNameFromUrl = (urlString: string) => { const getShortcutNameFromUrl = (urlString: string) => {
const matchResult = urlRegex.exec(urlString); const matchResult = urlRegex.exec(urlString);

View File

@ -1,38 +1,27 @@
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy"; import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import axios from "axios";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { CreateShortcutResponse, OpenGraphMetadata, Visibility } from "@/types/proto/api/v2/shortcut_service"; import { useStorageContext } from "@/context";
import { useShortcutStore } from "@/stores";
import type { Visibility } from "@/types/proto/api/v1/common";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
const generateTempName = (length = 6) => {
let result = "";
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
let counter = 0;
while (counter < length) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
};
interface State { interface State {
name: string; name: string;
title: string; title: string;
link: string; link: string;
} }
const CreateShortcutsButton = () => { const CreateShortcutButton = () => {
const [domain] = useStorage("domain"); const context = useStorageContext();
const [accessToken] = useStorage("access_token"); const shortcutStore = useShortcutStore();
const [shortcuts, setShortcuts] = useStorage("shortcuts");
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
name: "", name: "",
title: "", title: "",
link: "", link: "",
}); });
const [tag, setTag] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@ -53,7 +42,7 @@ const CreateShortcutsButton = () => {
const tab = tabs[0]; const tab = tabs[0];
setState((state) => ({ setState((state) => ({
...state, ...state,
name: generateTempName() + "-temp", name: "",
title: tab.title || "", title: tab.title || "",
link: tab.url || "", link: tab.url || "",
})); }));
@ -61,6 +50,18 @@ const CreateShortcutsButton = () => {
}); });
}; };
const generateRandomName = () => {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let name = "";
for (let i = 0; i < 8; i++) {
name += chars.charAt(Math.floor(Math.random() * chars.length));
}
setState((state) => ({
...state,
name,
}));
};
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => ({ setState((state) => ({
...state, ...state,
@ -82,6 +83,11 @@ const CreateShortcutsButton = () => {
})); }));
}; };
const handleTagsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setTag(text);
};
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (isLoading) { if (isLoading) {
return; return;
@ -93,25 +99,18 @@ const CreateShortcutsButton = () => {
setIsLoading(true); setIsLoading(true);
try { try {
const { const tags = tag.split(" ").filter(Boolean);
data: { shortcut }, await shortcutStore.createShortcut(
} = await axios.post<CreateShortcutResponse>( context.instanceUrl,
`${domain}/api/v2/shortcuts`, context.accessToken,
{ Shortcut.fromPartial({
name: state.name, name: state.name,
title: state.title, title: state.title,
link: state.link, link: state.link,
visibility: Visibility.PRIVATE, tags,
ogMetadata: OpenGraphMetadata.fromPartial({}), visibility: context.defaultVisibility as Visibility,
}, })
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
); );
setShortcuts([shortcut, ...shortcuts]);
toast.success("Shortcut created successfully"); toast.success("Shortcut created successfully");
setShowModal(false); setShowModal(false);
} catch (error: any) { } catch (error: any) {
@ -138,7 +137,18 @@ const CreateShortcutsButton = () => {
<div className="overflow-x-hidden w-full flex flex-col justify-start items-center"> <div className="overflow-x-hidden w-full flex flex-col justify-start items-center">
<div className="w-full flex flex-row justify-start items-center mb-2"> <div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Name</span> <span className="block w-12 mr-2 shrink-0">Name</span>
<Input className="grow" type="text" placeholder="Unique shortcut name" value={state.name} onChange={handleNameInputChange} /> <Input
className="grow"
type="text"
placeholder="Unique shortcut name"
value={state.name}
onChange={handleNameInputChange}
endDecorator={
<IconButton size="sm" onClick={generateRandomName}>
<Icon.RefreshCcw className="w-4 h-auto cursor-pointer" />
</IconButton>
}
/>
</div> </div>
<div className="w-full flex flex-row justify-start items-center mb-2"> <div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Title</span> <span className="block w-12 mr-2 shrink-0">Title</span>
@ -149,11 +159,15 @@ const CreateShortcutsButton = () => {
<Input <Input
className="grow" className="grow"
type="text" type="text"
placeholder="https://github.com/boojack/slash" placeholder="e.g., https://github.com/yourselfhosted/slash"
value={state.link} value={state.link}
onChange={handleLinkInputChange} onChange={handleLinkInputChange}
/> />
</div> </div>
<div className="w-full flex flex-row justify-start items-center mb-2">
<span className="block w-12 mr-2 shrink-0">Tags</span>
<Input className="grow" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
</div>
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2"> <div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}> <Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
@ -170,4 +184,4 @@ const CreateShortcutsButton = () => {
); );
}; };
export default CreateShortcutsButton; export default CreateShortcutButton;

View File

@ -1,12 +1,12 @@
import classNames from "classnames"; import classNames from "classnames";
import LogoBase64 from "data-base64:../..//assets/icon.png"; import Icon from "./Icon";
interface Props { interface Props {
className?: string; className?: string;
} }
const Logo = ({ className }: Props) => { const Logo = ({ className }: Props) => {
return <img className={classNames(className)} src={LogoBase64} alt="" />; return <Icon.CircleSlash className={classNames("dark:text-gray-500", className)} strokeWidth={1.5} />;
}; };
export default Logo; export default Logo;

View File

@ -1,32 +1,23 @@
import { IconButton } from "@mui/joy"; import { IconButton } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import axios from "axios";
import { useEffect } from "react"; import { useEffect } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { ListShortcutsResponse } from "@/types/proto/api/v2/shortcut_service"; import { useStorageContext } from "@/context";
import { useShortcutStore } from "@/stores";
import Icon from "./Icon"; import Icon from "./Icon";
const PullShortcutsButton = () => { const PullShortcutsButton = () => {
const [domain] = useStorage("domain"); const context = useStorageContext();
const [accessToken] = useStorage("access_token"); const shortcutStore = useShortcutStore();
const [, setShortcuts] = useStorage("shortcuts");
useEffect(() => { useEffect(() => {
if (domain && accessToken) { if (context.instanceUrl && context.accessToken) {
handlePullShortcuts(true); handlePullShortcuts(true);
} }
}, [domain, accessToken]); }, [context]);
const handlePullShortcuts = async (silence = false) => { const handlePullShortcuts = async (silence = false) => {
try { try {
const { await shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
data: { shortcuts },
} = await axios.get<ListShortcutsResponse>(`${domain}/api/v2/shortcuts`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
setShortcuts(shortcuts);
if (!silence) { if (!silence) {
toast.success("Shortcuts pulled"); toast.success("Shortcuts pulled");
} }

View File

@ -1,7 +1,7 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { useStorage } from "@plasmohq/storage/hook"; import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames"; import classNames from "classnames";
import { getFaviconWithGoogleS2 } from "@/helpers/utils"; import { getFaviconWithGoogleS2 } from "@/helpers/utils";
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -22,13 +22,13 @@ const ShortcutView = (props: Props) => {
<> <>
<div <div
className={classNames( className={classNames(
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800" "group w-auto px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800"
)} )}
> >
<div className="w-full flex flex-row justify-start items-center"> <div className="w-full flex flex-row justify-start items-center">
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}> <span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
{favicon ? ( {favicon ? (
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" /> <img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
) : ( ) : (
<Icon.CircleSlash className="w-full h-auto text-gray-400" /> <Icon.CircleSlash className="w-full h-auto text-gray-400" />
)} )}
@ -44,15 +44,14 @@ const ShortcutView = (props: Props) => {
<div className="truncate"> <div className="truncate">
<span className="dark:text-gray-400">{shortcut.title}</span> <span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? ( {shortcut.title ? (
<span className="text-gray-500">(s/{shortcut.name})</span> <span className="text-gray-500">({shortcut.name})</span>
) : ( ) : (
<> <>
<span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate dark:text-gray-400">{shortcut.name}</span> <span className="truncate dark:text-gray-400">{shortcut.name}</span>
</> </>
)} )}
</div> </div>
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0"> <span className="ml-1 cursor-pointer shrink-0 opacity-80">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" /> <Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span> </span>
</button> </button>

View File

@ -1,16 +1,24 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames"; import classNames from "classnames";
import { useShortcutStore } from "@/stores";
import Icon from "./Icon";
import ShortcutView from "./ShortcutView"; import ShortcutView from "./ShortcutView";
const ShortcutsContainer = () => { const ShortcutsContainer = () => {
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", (v) => (v ? v : [])); const shortcuts = useShortcutStore().getShortcutList();
return ( return (
<div className={classNames("w-full grid grid-cols-2 gap-2")}> <div>
{shortcuts.map((shortcut) => { <div className="w-full flex flex-row justify-start items-center mb-4">
return <ShortcutView key={shortcut.id} shortcut={shortcut} />; <a className="bg-blue-100 dark:bg-blue-500 dark:opacity-70 py-2 px-3 rounded-full border dark:border-blue-600 flex flex-row justify-start items-center cursor-pointer shadow">
})} <Icon.AlertCircle className="w-4 h-auto" />
<span className="mx-1 text-sm">Please make sure you have signed in your instance.</span>
</a>
</div>
<div className={classNames("w-full flex flex-row justify-start items-start flex-wrap gap-2")}>
{shortcuts.map((shortcut) => {
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
})}
</div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,27 @@
import { createContext, useContext } from "react";
import { Visibility } from "@/types/proto/api/v1/common";
interface Context {
instanceUrl?: string;
accessToken?: string;
defaultVisibility: string;
setInstanceUrl: (instanceUrl: string) => void;
setAccessToken: (accessToken: string) => void;
setDefaultVisibility: (visibility: string) => void;
}
export const StorageContext = createContext<Context>({
instanceUrl: undefined,
accessToken: undefined,
defaultVisibility: Visibility.PRIVATE,
setInstanceUrl: () => {},
setAccessToken: () => {},
setDefaultVisibility: () => {},
});
const useStorageContext = () => {
const context = useContext(StorageContext);
return context;
};
export default useStorageContext;

View File

@ -0,0 +1,4 @@
import useStorageContext from "./context";
import StorageContextProvider from "./provider";
export { useStorageContext, StorageContextProvider };

View File

@ -0,0 +1,66 @@
import { Storage } from "@plasmohq/storage";
import { useEffect, useState } from "react";
import { Visibility } from "@/types/proto/api/v1/common";
import { StorageContext } from "./context";
interface Props {
children: React.ReactNode;
}
const StorageContextProvider = ({ children }: Props) => {
const storage = new Storage();
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
const [defaultVisibility, setDefaultVisibility] = useState<Visibility>(Visibility.PRIVATE);
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
(async () => {
let instanceUrl = await storage.get("instance_url");
const accessToken = await storage.get("access_token");
const defaultVisibility = (await storage.get("default_visibility")) as Visibility;
// Migrate domain to instance_url.
const domain = await storage.get("domain");
if (domain) {
instanceUrl = domain;
await storage.remove("domain");
await storage.set("instance_url", instanceUrl);
}
setInstanceUrl(instanceUrl);
setAccessToken(accessToken);
setDefaultVisibility(defaultVisibility);
setIsInitialized(true);
})();
storage.watch({
instance_url: (c) => {
setInstanceUrl(c.newValue);
},
access_token: (c) => {
setAccessToken(c.newValue);
},
default_visibility: (c) => {
setDefaultVisibility(c.newValue as Visibility);
},
});
}, []);
return (
<StorageContext.Provider
value={{
instanceUrl,
accessToken,
defaultVisibility,
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
setAccessToken: (accessToken: string) => storage.set("access_token", accessToken),
setDefaultVisibility: (visibility: Visibility) => storage.set("default_visibility", visibility),
}}
>
{isInitialized && children}
</StorageContext.Provider>
);
};
export default StorageContextProvider;

View File

@ -1,9 +1,3 @@
import { isNull, isUndefined } from "lodash-es";
export const isNullorUndefined = (value: any) => {
return isNull(value) || isUndefined(value);
};
export const getFaviconWithGoogleS2 = (url: string) => { export const getFaviconWithGoogleS2 = (url: string) => {
try { try {
const urlObject = new URL(url); const urlObject = new URL(url);

View File

@ -1,14 +1,15 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy"; import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Toaster, toast } from "react-hot-toast"; import { Toaster, toast } from "react-hot-toast";
import { useShortcutStore } from "@/stores";
import Icon from "./components/Icon"; import Icon from "./components/Icon";
import Logo from "./components/Logo"; import Logo from "./components/Logo";
import PullShortcutsButton from "./components/PullShortcutsButton"; import PullShortcutsButton from "./components/PullShortcutsButton";
import ShortcutsContainer from "./components/ShortcutsContainer"; import ShortcutsContainer from "./components/ShortcutsContainer";
import { StorageContextProvider, useStorageContext } from "./context";
import useColorTheme from "./hooks/useColorTheme"; import useColorTheme from "./hooks/useColorTheme";
import "./style.css"; import "./style.css";
import { Visibility } from "./types/proto/api/v1/common";
interface SettingState { interface SettingState {
domain: string; domain: string;
@ -32,21 +33,21 @@ const colorThemeOptions = [
const IndexOptions = () => { const IndexOptions = () => {
const { colorTheme, setColorTheme } = useColorTheme(); const { colorTheme, setColorTheme } = useColorTheme();
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : "")); const context = useStorageContext();
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
const [settingState, setSettingState] = useState<SettingState>({ const [settingState, setSettingState] = useState<SettingState>({
domain, domain: context.instanceUrl || "",
accessToken, accessToken: context.accessToken || "",
}); });
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []); const shortcutStore = useShortcutStore();
const isInitialized = domain && accessToken; const shortcuts = shortcutStore.getShortcutList();
const isInitialized = context.instanceUrl && context.accessToken;
useEffect(() => { useEffect(() => {
setSettingState({ setSettingState({
domain, domain: context.instanceUrl || "",
accessToken, accessToken: context.accessToken || "",
}); });
}, [domain, accessToken]); }, [context]);
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => { const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
setSettingState((prevState) => ({ setSettingState((prevState) => ({
@ -56,8 +57,8 @@ const IndexOptions = () => {
}; };
const handleSaveSetting = () => { const handleSaveSetting = () => {
setDomain(settingState.domain); context.setInstanceUrl(settingState.domain);
setAccessToken(settingState.accessToken); context.setAccessToken(settingState.accessToken);
toast.success("Setting saved"); toast.success("Setting saved");
}; };
@ -65,12 +66,16 @@ const IndexOptions = () => {
setColorTheme(colorTheme as any); setColorTheme(colorTheme as any);
}; };
const handleDefaultVisibilitySelect = (value: Visibility) => {
context.setDefaultVisibility(value);
};
return ( return (
<div className="w-full"> <div className="w-full px-4">
<div className="w-full flex flex-row justify-center items-center"> <div className="w-full flex flex-row justify-center items-center">
<a <a
className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600" className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 mt-12 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow hover:underline hover:text-blue-600"
href="https://github.com/boojack/slash#browser-extension" href="https://github.com/yourselfhosted/slash#browser-extension"
target="_blank" target="_blank"
> >
<Icon.HelpCircle className="w-4 h-auto" /> <Icon.HelpCircle className="w-4 h-auto" />
@ -79,7 +84,7 @@ const IndexOptions = () => {
</a> </a>
</div> </div>
<div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start mt-12"> <div className="w-full max-w-lg mx-auto flex flex-col justify-start items-start py-12">
<h2 className="flex flex-row justify-start items-center mb-6 text-2xl dark:text-gray-400"> <h2 className="flex flex-row justify-start items-center mb-6 text-2xl dark:text-gray-400">
<Logo className="w-10 h-auto mr-2" /> <Logo className="w-10 h-auto mr-2" />
<span>Slash</span> <span>Slash</span>
@ -90,11 +95,11 @@ const IndexOptions = () => {
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<div className="w-full flex flex-col justify-start items-start mb-4"> <div className="w-full flex flex-col justify-start items-start mb-4">
<div className="mb-2 text-base w-full flex flex-row justify-between items-center"> <div className="mb-2 text-base w-full flex flex-row justify-between items-center">
<span className="dark:text-gray-400">Domain</span> <span className="dark:text-gray-400">Instance URL</span>
{domain !== "" && ( {context.instanceUrl !== "" && (
<a <a
className="text-sm flex flex-row justify-start items-center dark:text-gray-400 hover:underline hover:text-blue-600" className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
href={domain} href={context.instanceUrl}
target="_blank" target="_blank"
> >
<span className="mr-1">Go to my Slash</span> <span className="mr-1">Go to my Slash</span>
@ -106,7 +111,7 @@ const IndexOptions = () => {
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder="The domain of your Slash instance" placeholder="The url of your Slash instance. e.g., https://slash.example.com"
value={settingState.domain} value={settingState.domain}
onChange={(e) => setPartialSettingState({ domain: e.target.value })} onChange={(e) => setPartialSettingState({ domain: e.target.value })}
/> />
@ -119,7 +124,7 @@ const IndexOptions = () => {
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder="The access token of your Slash instance" placeholder="An available access token of your account."
value={settingState.accessToken} value={settingState.accessToken}
onChange={(e) => setPartialSettingState({ accessToken: e.target.value })} onChange={(e) => setPartialSettingState({ accessToken: e.target.value })}
/> />
@ -132,9 +137,9 @@ const IndexOptions = () => {
<Divider className="!my-6" /> <Divider className="!my-6" />
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p> <p className="text-base font-semibold leading-6 mb-2 text-gray-900 dark:text-gray-500">Preference</p>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center gap-x-1"> <div className="flex flex-row justify-start items-center gap-x-1">
<span className="dark:text-gray-400">Color Theme</span> <span className="dark:text-gray-400">Color Theme</span>
</div> </div>
@ -148,6 +153,17 @@ const IndexOptions = () => {
})} })}
</Select> </Select>
</div> </div>
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center gap-x-1">
<span className="dark:text-gray-400">Default Visibility</span>
</div>
<Select defaultValue={context.defaultVisibility} onChange={(_, value) => handleDefaultVisibilitySelect(value as Visibility)}>
<Option value={Visibility.PRIVATE}>Private</Option>
<Option value={Visibility.WORKSPACE}>Workspace</Option>
<Option value={Visibility.PUBLIC}>Public</Option>
</Select>
</div>
</div> </div>
{isInitialized && ( {isInitialized && (
@ -169,10 +185,12 @@ const IndexOptions = () => {
const Options = () => { const Options = () => {
return ( return (
<CssVarsProvider> <StorageContextProvider>
<IndexOptions /> <CssVarsProvider>
<Toaster position="top-right" /> <IndexOptions />
</CssVarsProvider> <Toaster position="top-center" />
</CssVarsProvider>
</StorageContextProvider>
); );
}; };

View File

@ -1,21 +1,30 @@
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy"; import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
import { useStorage } from "@plasmohq/storage/hook"; import { useEffect } from "react";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import CreateShortcutsButton from "@/components/CreateShortcutsButton"; import CreateShortcutButton from "@/components/CreateShortcutButton";
import Icon from "@/components/Icon"; import Icon from "@/components/Icon";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import PullShortcutsButton from "@/components/PullShortcutsButton"; import PullShortcutsButton from "@/components/PullShortcutsButton";
import ShortcutsContainer from "@/components/ShortcutsContainer"; import ShortcutsContainer from "@/components/ShortcutsContainer";
import { useShortcutStore } from "@/stores";
import { StorageContextProvider, useStorageContext } from "./context";
import useColorTheme from "./hooks/useColorTheme"; import useColorTheme from "./hooks/useColorTheme";
import "./style.css"; import "./style.css";
const IndexPopup = () => { const IndexPopup = () => {
useColorTheme(); useColorTheme();
const [domain] = useStorage<string>("domain", ""); const context = useStorageContext();
const [accessToken] = useStorage<string>("access_token", ""); const shortcutStore = useShortcutStore();
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []); const shortcuts = shortcutStore.getShortcutList();
const isInitialized = domain && accessToken; const isInitialized = context.instanceUrl && context.accessToken;
useEffect(() => {
if (!isInitialized) {
return;
}
shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
}, [isInitialized]);
const handleSettingButtonClick = () => { const handleSettingButtonClick = () => {
chrome.runtime.openOptionsPage(); chrome.runtime.openOptionsPage();
@ -30,7 +39,7 @@ const IndexPopup = () => {
<div className="w-full min-w-[512px] px-4 pt-4"> <div className="w-full min-w-[512px] px-4 pt-4">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center dark:text-gray-400"> <div className="flex flex-row justify-start items-center dark:text-gray-400">
<Logo className="w-6 h-auto mr-2" /> <Logo className="w-6 h-auto mr-1" />
<span className="">Slash</span> <span className="">Slash</span>
{isInitialized && ( {isInitialized && (
<> <>
@ -41,7 +50,7 @@ const IndexPopup = () => {
</> </>
)} )}
</div> </div>
<div>{isInitialized && <CreateShortcutsButton />}</div> <div>{isInitialized && <CreateShortcutButton />}</div>
</div> </div>
<div className="w-full mt-4"> <div className="w-full mt-4">
@ -62,14 +71,21 @@ const IndexPopup = () => {
<IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}> <IconButton size="sm" variant="plain" color="neutral" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400" /> <Icon.Settings className="w-5 h-auto text-gray-500 dark:text-gray-400" />
</IconButton> </IconButton>
<IconButton size="sm" variant="plain" color="neutral" component="a" href="https://github.com/boojack/slash" target="_blank"> <IconButton
size="sm"
variant="plain"
color="neutral"
component="a"
href="https://github.com/yourselfhosted/slash"
target="_blank"
>
<Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" /> <Icon.Github className="w-5 h-auto text-gray-500 dark:text-gray-400" />
</IconButton> </IconButton>
</div> </div>
<div className="flex flex-row justify-end items-center"> <div className="flex flex-row justify-end items-center">
<a <a
className="text-sm flex flex-row justify-start items-center text-gray-500 dark:text-gray-400 hover:underline hover:text-blue-600" className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
href={domain} href={context.instanceUrl}
target="_blank" target="_blank"
> >
<span className="mr-1">Go to my Slash</span> <span className="mr-1">Go to my Slash</span>
@ -81,10 +97,10 @@ const IndexPopup = () => {
) : ( ) : (
<div className="w-full flex flex-col justify-start items-center"> <div className="w-full flex flex-col justify-start items-center">
<Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" /> <Icon.Cookie strokeWidth={1} className="w-20 h-auto mb-4 text-gray-400" />
<p className="dark:text-gray-400">Please set your domain and access token first.</p> <p className="dark:text-gray-400">Please set your instance URL and access token first.</p>
<div className="w-full flex flex-row justify-center items-center py-4"> <div className="w-full flex flex-row justify-center items-center py-4">
<Button size="sm" color="primary" onClick={handleSettingButtonClick}> <Button size="sm" color="primary" onClick={handleSettingButtonClick}>
<Icon.Settings className="w-5 h-auto mr-1" /> Setting <Icon.Settings className="w-5 h-auto mr-1" /> Go to Setting
</Button> </Button>
<span className="mx-2 dark:text-gray-400">Or</span> <span className="mx-2 dark:text-gray-400">Or</span>
<Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}> <Button size="sm" variant="outlined" color="neutral" onClick={handleRefreshButtonClick}>
@ -100,10 +116,12 @@ const IndexPopup = () => {
const Popup = () => { const Popup = () => {
return ( return (
<CssVarsProvider> <StorageContextProvider>
<IndexPopup /> <CssVarsProvider>
<Toaster position="top-right" /> <IndexPopup />
</CssVarsProvider> <Toaster position="top-center" />
</CssVarsProvider>
</StorageContextProvider>
); );
}; };

View File

@ -0,0 +1,3 @@
import useShortcutStore from "./shortcut";
export { useShortcutStore };

View File

@ -0,0 +1,55 @@
import axios from "axios";
import { create } from "zustand";
import { combine } from "zustand/middleware";
import { CreateShortcutResponse, ListShortcutsResponse, Shortcut } from "@/types/proto/api/v1/shortcut_service";
interface State {
shortcutMapById: Record<number, Shortcut>;
}
const getDefaultState = (): State => {
return {
shortcutMapById: {},
};
};
const useShortcutStore = create(
combine(getDefaultState(), (set, get) => ({
fetchShortcutList: async (instanceUrl: string, accessToken: string) => {
const {
data: { shortcuts },
} = await axios.get<ListShortcutsResponse>(`${instanceUrl}/api/v1/shortcuts`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const shortcutMap = get().shortcutMapById;
shortcuts.forEach((shortcut) => {
shortcutMap[shortcut.id] = shortcut;
});
set({ shortcutMapById: shortcutMap });
return shortcuts;
},
getShortcutList: () => {
return Object.values(get().shortcutMapById);
},
createShortcut: async (instanceUrl: string, accessToken: string, create: Shortcut) => {
const {
data: { shortcut },
} = await axios.post<CreateShortcutResponse>(`${instanceUrl}/api/v1/shortcuts`, create, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!shortcut) {
throw new Error(`Failed to create shortcut`);
}
const shortcutMap = get().shortcutMapById;
shortcutMap[shortcut.id] = shortcut;
set({ shortcutMapById: shortcutMap });
return shortcut;
},
}))
);
export default useShortcutStore;

View File

@ -76,7 +76,8 @@
"enable-user-signup": { "enable-user-signup": {
"self": "Enable user signup", "self": "Enable user signup",
"description": "Once enabled, other users can signup." "description": "Once enabled, other users can signup."
} },
"default-visibility": "Default visibility"
} }
} }
} }

View File

@ -76,7 +76,8 @@
"enable-user-signup": { "enable-user-signup": {
"self": "启用用户注册", "self": "启用用户注册",
"description": "允许其他用户注册新账号" "description": "允许其他用户注册新账号"
} },
"default-visibility": "默认可见性"
} }
} }
} }

View File

@ -2,9 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/logo.png" type="image/*" /> <link rel="icon" href="/logo.svg" type="image/*" />
<meta name="theme-color" content="#FFFFFF" /> <meta name="theme-color" content="#FFFFFF" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- slash.metadata -->
<title>Slash</title> <title>Slash</title>
</head> </head>
<body> <body>

View File

@ -5,50 +5,53 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"serve": "vite preview", "serve": "vite preview",
"lint": "eslint --ext .js,.ts,.tsx, src", "lint": "eslint --ext .js,.ts,.tsx, src",
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix" "lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
"postinstall": "cd ../../proto && buf generate"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.5",
"@mui/joy": "5.0.0-beta.7", "@mui/joy": "5.0.0-beta.30",
"@reduxjs/toolkit": "^1.9.6", "@reduxjs/toolkit": "^1.9.7",
"axios": "^0.27.2", "classnames": "^2.5.1",
"classnames": "^2.3.2",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"i18next": "^23.5.1", "i18next": "^23.11.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.263.1", "lucide-react": "^0.312.0",
"nice-grpc-web": "^3.3.1", "nice-grpc-web": "^3.3.3",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-i18next": "^13.2.2", "react-i18next": "^14.1.0",
"react-redux": "^8.1.2", "react-router-dom": "^6.22.3",
"react-router-dom": "^6.16.0", "react-use": "^17.5.0",
"react-use": "^17.4.0", "tailwindcss": "^3.4.3",
"tailwindcss": "^3.3.3", "zustand": "^4.5.2"
"zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.2.0", "@bufbuild/buf": "^1.30.1",
"@types/lodash-es": "^4.17.9", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/react": "^18.2.23", "@types/lodash-es": "^4.17.12",
"@types/react-dom": "^18.2.8", "@types/react": "^18.2.79",
"@typescript-eslint/eslint-plugin": "^6.7.3", "@types/react-dom": "^18.2.25",
"@typescript-eslint/parser": "^6.7.3", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@vitejs/plugin-react-swc": "^3.4.0", "@typescript-eslint/parser": "^6.21.0",
"autoprefixer": "^10.4.16", "@vitejs/plugin-react-swc": "^3.6.0",
"eslint": "^8.50.0", "autoprefixer": "^10.4.19",
"eslint-config-prettier": "^8.10.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.34.1",
"long": "^5.2.3", "long": "^5.2.3",
"postcss": "^8.4.31", "postcss": "^8.4.38",
"prettier": "2.6.2", "prettier": "2.6.2",
"protobufjs": "^7.2.5", "protobufjs": "^7.2.6",
"typescript": "^5.2.2", "typescript": "^5.4.5",
"vite": "^4.4.9" "vite": "^5.2.9"
},
"resolutions": {
"csstype": "3.1.2"
} }
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-slash"><line x1="9" x2="15" y1="15" y2="9"/><circle cx="12" cy="12" r="10"/></svg>frontend/web/public/logo.svg

After

Width:  |  Height:  |  Size: 319 B

View File

@ -1,9 +1,8 @@
import { useColorScheme } from "@mui/joy"; import { useColorScheme } from "@mui/joy";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import DemoBanner from "./components/DemoBanner"; import DemoBanner from "@/components/DemoBanner";
import useUserStore from "./stores/v1/user"; import { useUserStore, useWorkspaceStore } from "@/stores";
import useWorkspaceStore from "./stores/v1/workspace";
function App() { function App() {
const { mode: colorScheme } = useColorScheme(); const { mode: colorScheme } = useColorScheme();
@ -16,7 +15,7 @@ function App() {
try { try {
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]); await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
} catch (error) { } catch (error) {
// do nth // Do nothing.
} }
setLoading(false); setLoading(false);
})(); })();

View File

@ -21,11 +21,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div className="max-w-full w-80 sm:w-96"> <div className="max-w-full w-80 sm:w-96">
<p> <p>
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform. <span className="font-medium">Slash</span>: An open source, self-hosted links shortener and sharing platform.
</p> </p>
<div className="mt-1"> <div className="mt-1">
<span className="mr-2">See more in</span> <span className="mr-2">See more in</span>
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank"> <Link variant="plain" href="https://github.com/yourselfhosted/slash" target="_blank">
GitHub GitHub
</Link> </Link>
</div> </div>

View File

@ -45,7 +45,7 @@ const Alert: React.FC<Props> = (props: Props) => {
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">{title}</span> <span className="text-lg font-medium">{title}</span>
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -1,28 +1,29 @@
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as api from "../helpers/api"; import { shortcutServiceClient } from "@/grpcweb";
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
shortcutId: ShortcutId; shortcutId: number;
className?: string; className?: string;
} }
const AnalyticsView: React.FC<Props> = (props: Props) => { const AnalyticsView: React.FC<Props> = (props: Props) => {
const { shortcutId, className } = props; const { shortcutId, className } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [analytics, setAnalytics] = useState<AnalysisData | null>(null); const [analytics, setAnalytics] = useState<GetShortcutAnalyticsResponse | null>(null);
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser"); const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
useEffect(() => { useEffect(() => {
api.getShortcutAnalytics(shortcutId).then(({ data }) => { shortcutServiceClient.getShortcutAnalytics({ id: shortcutId }).then((response) => {
setAnalytics(data); setAnalytics(response);
}); });
}, []); }, []);
return ( return (
<div className={classNames("w-full", className)}> <div className={classNames("relative w-full", className)}>
{analytics ? ( {analytics ? (
<> <>
<div className="w-full"> <div className="w-full">
@ -34,7 +35,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
<span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span> <span className="py-2 pr-2 text-right font-semibold text-sm text-gray-500">{t("analytics.visitors")}</span>
</div> </div>
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800"> <div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
{analytics.referenceData.map((reference) => ( {analytics.references.length === 0 && (
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No data found.</p>
</div>
)}
{analytics.references.map((reference) => (
<div key={reference.name} className="w-full flex flex-row justify-between items-center"> <div key={reference.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500"> <span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900 dark:text-gray-500">
{reference.name ? ( {reference.name ? (
@ -89,7 +96,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span> <span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
</div> </div>
<div className="w-full divide-y divide-gray-200 dark:divide-zinc-800"> <div className="w-full divide-y divide-gray-200 dark:divide-zinc-800">
{analytics.browserData.map((reference) => ( {analytics.browsers.length === 0 && (
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No data found.</p>
</div>
)}
{analytics.browsers.map((reference) => (
<div key={reference.name} className="w-full flex flex-row justify-between items-center"> <div key={reference.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500"> <span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate dark:text-gray-500">
{reference.name || "Unknown"} {reference.name || "Unknown"}
@ -106,7 +119,13 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span> <span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">{t("analytics.visitors")}</span>
</div> </div>
<div className="w-full divide-y divide-gray-200"> <div className="w-full divide-y divide-gray-200">
{analytics.deviceData.map((device) => ( {analytics.devices.length === 0 && (
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No data found.</p>
</div>
)}
{analytics.devices.map((device) => (
<div key={device.name} className="w-full flex flex-row justify-between items-center"> <div key={device.name} className="w-full flex flex-row justify-between items-center">
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span> <span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{device.name || "Unknown"}</span>
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span> <span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
@ -119,7 +138,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
</div> </div>
</> </>
) : ( ) : (
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80"> <div className="absolute py-12 w-full flex flex-row justify-center items-center opacity-80">
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" /> <Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
{t("common.loading")} {t("common.loading")}
</div> </div>

View File

@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLoading from "../hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useUserStore from "../stores/v1/user"; import { useUserStore } from "@/stores";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -54,7 +54,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
toast("Password changed"); toast("Password changed");
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
requestState.setFinish(); requestState.setFinish();
}; };
@ -62,7 +62,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">Change Password</span> <span className="text-lg font-medium">Change Password</span>
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -0,0 +1,133 @@
import classNames from "classnames";
import copy from "copy-to-clipboard";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { useCollectionStore, useShortcutStore, useUserStore } from "@/stores";
import { Collection } from "@/types/proto/api/v1/collection_service";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { showCommonDialog } from "./Alert";
import CreateCollectionDialog from "./CreateCollectionDrawer";
import Icon from "./Icon";
import ShortcutView from "./ShortcutView";
import Dropdown from "./common/Dropdown";
interface Props {
collection: Collection;
}
const CollectionView = (props: Props) => {
const { collection } = props;
const { t } = useTranslation();
const { sm } = useResponsiveWidth();
const navigateTo = useNavigateTo();
const userStore = useUserStore();
const currentUser = userStore.getCurrentUser();
const collectionStore = useCollectionStore();
const shortcutList = useShortcutStore().getShortcutList();
const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
const shortcuts = collection.shortcutIds
.map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId))
.filter(Boolean) as any as Shortcut[];
const showAdminActions = currentUser.id === collection.creatorId;
const handleCopyCollectionLink = () => {
copy(absolutifyLink(`/c/${collection.name}`));
toast.success("Collection link copied to clipboard.");
};
const handleDeleteCollectionButtonClick = () => {
showCommonDialog({
title: "Delete Collection",
content: `Are you sure to delete collection \`${collection.name}\`? You cannot undo this action.`,
style: "danger",
onConfirm: async () => {
await collectionStore.deleteCollection(collection.id);
},
});
};
const handleShortcutClick = (shortcut: Shortcut) => {
navigateTo(`/shortcut/${shortcut.id}`);
};
return (
<>
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
<div className="w-auto flex flex-col justify-start items-start mr-2">
<div className="w-full truncate">
<Link className="leading-6 font-medium dark:text-gray-400" to={`/c/${collection.name}`} unstable_viewTransition>
{collection.title}
</Link>
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400" onClick={handleCopyCollectionLink}>
(c/{collection.name})
</span>
</div>
<p className="text-sm text-gray-500">{collection.description}</p>
</div>
<div className="flex flex-row justify-end items-center shrink-0 gap-2">
<Link className="w-full text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`} target="_blank">
<Icon.Share className="w-4 h-auto" />
</Link>
{showAdminActions && (
<Dropdown
trigger={
<button className="flex flex-row justify-center items-center rounded text-gray-400 cursor-pointer hover:text-gray-500">
<Icon.MoreVertical className="w-4 h-auto" />
</button>
}
actionsClassName="!w-28 text-sm"
actions={
<>
<button
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => setShowEditDialog(true)}
>
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
</button>
<button
className="w-full px-2 flex flex-row justify-start items-center text-left text-red-600 dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => {
handleDeleteCollectionButtonClick();
}}
>
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
</button>
</>
}
></Dropdown>
)}
</div>
</div>
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">
{shortcuts.map((shortcut) => {
return (
<ShortcutView
key={shortcut.id}
className="!w-auto"
shortcut={shortcut}
alwaysShowLink={!sm}
onClick={() => handleShortcutClick(shortcut)}
/>
);
})}
</div>
</div>
{showEditDialog && (
<CreateCollectionDialog
collectionId={collection.id}
onClose={() => setShowEditDialog(false)}
onConfirm={() => setShowEditDialog(false)}
/>
)}
</>
);
};
export default CollectionView;

View File

@ -3,8 +3,8 @@ import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import useLoading from "../hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useUserStore from "../stores/v1/user"; import { useUserStore } from "@/stores";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -80,14 +80,14 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
onClose(); onClose();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
}; };
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">Create Access Token</span> <span className="text-lg font-medium">Create Access Token</span>
<Button variant="plain" onClick={onClose}> <Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -0,0 +1,280 @@
import { Button, DialogActions, DialogContent, DialogTitle, Drawer, Input, ModalClose, Radio, RadioGroup } from "@mui/joy";
import { isUndefined } from "lodash-es";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import useLoading from "@/hooks/useLoading";
import { useCollectionStore, useShortcutStore, useWorkspaceStore } from "@/stores";
import { Collection } from "@/types/proto/api/v1/collection_service";
import { Visibility } from "@/types/proto/api/v1/common";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { convertVisibilityFromPb } from "@/utils/visibility";
import Icon from "./Icon";
import ShortcutView from "./ShortcutView";
interface Props {
collectionId?: number;
onClose: () => void;
onConfirm?: () => void;
}
interface State {
collectionCreate: Collection;
}
const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, collectionId } = props;
const { t } = useTranslation();
const workspaceStore = useWorkspaceStore();
const collectionStore = useCollectionStore();
const shortcutList = useShortcutStore().getShortcutList();
const [state, setState] = useState<State>({
collectionCreate: Collection.fromPartial({
visibility: Visibility.PRIVATE,
}),
});
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
const isCreating = isUndefined(collectionId);
const loadingState = useLoading(!isCreating);
const requestState = useLoading(false);
const unselectedShortcuts = shortcutList
.filter((shortcut) => {
if (state.collectionCreate.visibility === Visibility.PUBLIC) {
return shortcut.visibility === Visibility.PUBLIC;
} else if (state.collectionCreate.visibility === Visibility.WORKSPACE) {
return shortcut.visibility === Visibility.PUBLIC || shortcut.visibility === Visibility.WORKSPACE;
} else {
return true;
}
})
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
useEffect(() => {
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
visibility: workspaceStore.setting.defaultVisibility,
}),
});
}
}, []);
useEffect(() => {
(async () => {
if (collectionId) {
const collection = await collectionStore.getOrFetchCollectionById(collectionId);
if (collection) {
setState({
...state,
collectionCreate: Object.assign(state.collectionCreate, {
...collection,
}),
});
setSelectedShortcuts(
collection.shortcutIds
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
.filter(Boolean) as Shortcut[]
);
loadingState.setFinish();
}
}
})();
}, [collectionId]);
if (loadingState.isLoading) {
return null;
}
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
name: e.target.value.replace(/\s+/g, "-"),
}),
});
};
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
title: e.target.value,
}),
});
};
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
visibility: e.target.value,
}),
});
};
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
description: e.target.value,
}),
});
};
const handleSaveBtnClick = async () => {
if (!state.collectionCreate.name || !state.collectionCreate.title) {
toast.error("Please fill in required fields.");
return;
}
if (selectedShortcuts.length === 0) {
toast.error("Please select at least one shortcut.");
return;
}
try {
if (!isCreating) {
await collectionStore.updateCollection(
{
id: collectionId,
name: state.collectionCreate.name,
title: state.collectionCreate.title,
description: state.collectionCreate.description,
visibility: state.collectionCreate.visibility,
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
},
["name", "title", "description", "visibility", "shortcut_ids"]
);
} else {
await collectionStore.createCollection({
...state.collectionCreate,
shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id),
});
}
if (onConfirm) {
onConfirm();
} else {
onClose();
}
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
};
return (
<Drawer anchor="right" open={true} onClose={onClose}>
<DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
<ModalClose />
<DialogContent className="w-full max-w-full">
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Name <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="text"
placeholder="The memorable name of the collection"
value={state.collectionCreate.name}
onChange={handleNameInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Title <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="A short title to describe your collection"
value={state.collectionCreate.title}
onChange={handleTitleInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Description</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="A slightly longer description"
value={state.collectionCreate.description}
onChange={handleDescriptionInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Visibility</span>
<div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
<Radio value={Visibility.WORKSPACE} label={t(`shortcut.visibility.workspace.self`)} />
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
</RadioGroup>
</div>
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
{t(`shortcut.visibility.${convertVisibilityFromPb(state.collectionCreate.visibility).toLowerCase()}.description`)}
</p>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<p className="mb-2">
<span>Shortcuts</span>
<span className="opacity-60">({selectedShortcuts.length})</span>
{selectedShortcuts.length === 0 && <span className="ml-2 italic opacity-80 text-sm">(Select a shortcut first)</span>}
</p>
<div className="w-full py-1 px-px flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2">
{selectedShortcuts.map((shortcut) => {
return (
<ShortcutView
key={shortcut.id}
className="!w-auto select-none max-w-[40%] cursor-pointer bg-gray-100 shadow dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400"
shortcut={shortcut}
onClick={() => {
setSelectedShortcuts([...selectedShortcuts.filter((selectedShortcut) => selectedShortcut.id !== shortcut.id)]);
}}
/>
);
})}
{unselectedShortcuts.map((shortcut) => {
return (
<ShortcutView
key={shortcut.id}
className="!w-auto select-none max-w-[40%] border-dashed cursor-pointer"
shortcut={shortcut}
onClick={() => {
setSelectedShortcuts([...selectedShortcuts, shortcut]);
}}
/>
);
})}
{selectedShortcuts.length + unselectedShortcuts.length === 0 && (
<div className="w-full flex flex-row justify-center items-center text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No shortcuts found.</p>
</div>
)}
</div>
</div>
</div>
</DialogContent>
<DialogActions>
<div className="w-full flex flex-row justify-end items-center px-3 py-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.save")}
</Button>
</div>
</DialogActions>
</Drawer>
);
};
export default CreateCollectionDrawer;

View File

@ -1,57 +1,84 @@
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy"; import {
Button,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Drawer,
Input,
ModalClose,
Radio,
RadioGroup,
Textarea,
} from "@mui/joy";
import classnames from "classnames"; import classnames from "classnames";
import { isUndefined, uniq } from "lodash-es"; import { isUndefined, uniq } from "lodash-es";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAppSelector } from "@/stores"; import useLoading from "@/hooks/useLoading";
import useLoading from "../hooks/useLoading"; import { useWorkspaceStore, useShortcutStore } from "@/stores";
import { shortcutService } from "../services"; import { getShortcutUpdateMask } from "@/stores/shortcut";
import { Visibility } from "@/types/proto/api/v1/common";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { convertVisibilityFromPb } from "@/utils/visibility";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
shortcutId?: ShortcutId; shortcutId?: number;
initialShortcut?: Partial<Shortcut>; initialShortcut?: Partial<Shortcut>;
onClose: () => void; onClose: () => void;
onConfirm?: () => void; onConfirm?: () => void;
} }
interface State { interface State {
shortcutCreate: ShortcutCreate; shortcutCreate: Shortcut;
} }
const visibilities: Visibility[] = ["PRIVATE", "WORKSPACE", "PUBLIC"]; const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, shortcutId, initialShortcut } = props; const { onClose, onConfirm, shortcutId, initialShortcut } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { shortcutList } = useAppSelector((state) => state.shortcut);
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
shortcutCreate: { shortcutCreate: Shortcut.fromPartial({
name: "", visibility: Visibility.PUBLIC,
link: "", ogMetadata: {
title: "",
description: "",
visibility: "PRIVATE",
tags: [],
openGraphMetadata: {
title: "", title: "",
description: "", description: "",
image: "", image: "",
}, },
...initialShortcut, ...initialShortcut,
}, }),
}); });
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false); const shortcutStore = useShortcutStore();
const workspaceStore = useWorkspaceStore();
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false); const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
const shortcutList = shortcutStore.getShortcutList();
const [tag, setTag] = useState<string>(""); const [tag, setTag] = useState<string>("");
const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat()); const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat());
const requestState = useLoading(false);
const isCreating = isUndefined(shortcutId); const isCreating = isUndefined(shortcutId);
const loadingState = useLoading(!isCreating);
const requestState = useLoading(false);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
useEffect(() => {
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, {
visibility: workspaceStore.setting.defaultVisibility,
}),
});
}
}, []);
useEffect(() => { useEffect(() => {
if (shortcutId) { if (shortcutId) {
const shortcut = shortcutService.getShortcutById(shortcutId); const shortcut = shortcutStore.getShortcutById(shortcutId);
if (shortcut) { if (shortcut) {
setState({ setState({
...state, ...state,
@ -61,20 +88,18 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
title: shortcut.title, title: shortcut.title,
description: shortcut.description, description: shortcut.description,
visibility: shortcut.visibility, visibility: shortcut.visibility,
openGraphMetadata: shortcut.openGraphMetadata, ogMetadata: shortcut.ogMetadata,
}), }),
}); });
setTag(shortcut.tags.join(" ")); setTag(shortcut.tags.join(" "));
loadingState.setFinish();
} }
} }
}, [shortcutId]); }, [shortcutId]);
const setPartialState = (partialState: Partial<State>) => { if (loadingState.isLoading) {
setState({ return null;
...state, }
...partialState,
});
};
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
@ -124,8 +149,8 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, { shortcutCreate: Object.assign(state.shortcutCreate, {
openGraphMetadata: { ogMetadata: {
...state.shortcutCreate.openGraphMetadata, ...state.shortcutCreate.ogMetadata,
image: e.target.value, image: e.target.value,
}, },
}), }),
@ -135,8 +160,8 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({ setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, { shortcutCreate: Object.assign(state.shortcutCreate, {
openGraphMetadata: { ogMetadata: {
...state.shortcutCreate.openGraphMetadata, ...state.shortcutCreate.ogMetadata,
title: e.target.value, title: e.target.value,
}, },
}), }),
@ -146,8 +171,8 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPartialState({ setPartialState({
shortcutCreate: Object.assign(state.shortcutCreate, { shortcutCreate: Object.assign(state.shortcutCreate, {
openGraphMetadata: { ogMetadata: {
...state.shortcutCreate.openGraphMetadata, ...state.shortcutCreate.ogMetadata,
description: e.target.value, description: e.target.value,
}, },
}), }),
@ -163,27 +188,25 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
}; };
const handleSaveBtnClick = async () => { const handleSaveBtnClick = async () => {
if (!state.shortcutCreate.name) { if (!state.shortcutCreate.name || !state.shortcutCreate.link) {
toast.error("Name is required"); toast.error("Please fill in required fields.");
return; return;
} }
try { try {
const tags = tag.split(" ").filter(Boolean);
if (shortcutId) { if (shortcutId) {
await shortcutService.patchShortcut({ const originShortcut = shortcutStore.getShortcutById(shortcutId);
id: shortcutId, const updatingShortcut = {
name: state.shortcutCreate.name,
link: state.shortcutCreate.link,
title: state.shortcutCreate.title,
description: state.shortcutCreate.description,
visibility: state.shortcutCreate.visibility,
tags: tag.split(" ").filter(Boolean),
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
});
} else {
await shortcutService.createShortcut({
...state.shortcutCreate, ...state.shortcutCreate,
tags: tag.split(" ").filter(Boolean), id: shortcutId,
tags,
};
await shortcutStore.updateShortcut(updatingShortcut, getShortcutUpdateMask(originShortcut, updatingShortcut));
} else {
await shortcutStore.createShortcut({
...state.shortcutCreate,
tags,
}); });
} }
@ -194,48 +217,66 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
} }
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
}; };
return ( return (
<Modal open={true}> <Drawer anchor="right" open={true} onClose={onClose}>
<ModalDialog> <DialogTitle>{isCreating ? "Create Shortcut" : "Edit Shortcut"}</DialogTitle>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4"> <ModalClose />
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span> <DialogContent className="w-full max-w-full">
<Button variant="plain" onClick={onClose}> <div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden">
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Name</span> <span className="mb-2">
<div className="relative w-full"> Name <span className="text-red-600">*</span>
<Input </span>
className="w-full"
type="text"
placeholder="Unique shortcut name"
value={state.shortcutCreate.name}
onChange={handleNameInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Destination URL</span>
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder="https://github.com/boojack/slash" placeholder="The memorable name of the shortcut"
value={state.shortcutCreate.name}
onChange={handleNameInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Link <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="text"
placeholder="The destination link of the shortcut"
value={state.shortcutCreate.link} value={state.shortcutCreate.link}
onChange={handleLinkInputChange} onChange={handleLinkInputChange}
/> />
</div> </div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Title</span>
<Input
className="w-full"
type="text"
placeholder="The title of the shortcut"
value={state.shortcutCreate.title}
onChange={handleTitleInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Description</span>
<Input
className="w-full"
type="text"
placeholder="A short description of the shortcut"
value={state.shortcutCreate.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3"> <div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Tags</span> <span className="mb-2">Tags</span>
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} /> <Input className="w-full" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
{tagSuggestions.length > 0 && ( {tagSuggestions.length > 0 && (
<div className="w-full flex flex-row justify-start items-start mt-2"> <div className="w-full flex flex-row justify-start items-start mt-2">
<Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-600" /> <Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-500" />
<div className="w-auto flex flex-row justify-start items-start flex-wrap gap-x-2 gap-y-1"> <div className="w-auto flex flex-row justify-start items-start flex-wrap gap-x-2 gap-y-1">
{tagSuggestions.map((tag) => ( {tagSuggestions.map((tag) => (
<span <span
@ -254,57 +295,17 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<span className="mb-2">Visibility</span> <span className="mb-2">Visibility</span>
<div className="w-full flex flex-row justify-start items-center text-base"> <div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}> <RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
{visibilities.map((visibility) => ( <Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
<Radio key={visibility} value={visibility} label={t(`shortcut.visibility.${visibility.toLowerCase()}.self`)} /> <Radio value={Visibility.WORKSPACE} label={t(`shortcut.visibility.workspace.self`)} />
))} <Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
</RadioGroup> </RadioGroup>
</div> </div>
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md"> <p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)} {t(`shortcut.visibility.${convertVisibilityFromPb(state.shortcutCreate.visibility).toLowerCase()}.description`)}
</p> </p>
</div> </div>
<Divider className="text-gray-500">Optional</Divider> <Divider className="text-gray-500">More</Divider>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3 dark:border-zinc-800"> <div className="w-full flex flex-col justify-start items-start border rounded-md mt-3 overflow-hidden dark:border-zinc-800">
<div
className={classnames(
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
showAdditionalFields ? "bg-gray-100 border-b dark:bg-zinc-800 dark:border-b-zinc-700" : ""
)}
onClick={() => setShowAdditionalFields(!showAdditionalFields)}
>
<span className="text-sm">Additional fields</span>
<button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showAdditionalFields ? "transform rotate-180" : "")} />
</button>
</div>
{showAdditionalFields && (
<div className="w-full px-2 py-1">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Title</span>
<Input
className="w-full"
type="text"
placeholder="Title"
size="sm"
value={state.shortcutCreate.title}
onChange={handleTitleInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2 text-sm">Description</span>
<Input
className="w-full"
type="text"
placeholder="Github repo for slash"
size="sm"
value={state.shortcutCreate.description}
onChange={handleDescriptionInputChange}
/>
</div>
</div>
)}
</div>
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden dark:border-zinc-800">
<div <div
className={classnames( className={classnames(
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800", "w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800",
@ -312,7 +313,10 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
)} )}
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)} onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
> >
<span className="text-sm flex flex-row justify-start items-center">Social media metadata</span> <span className="text-sm flex flex-row justify-start items-center">
Social media metadata
<Icon.Sparkles className="w-4 h-auto shrink-0 ml-1 text-blue-600 dark:text-blue-500" />
</span>
<button className="w-7 h-7 p-1 rounded-md"> <button className="w-7 h-7 p-1 rounded-md">
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} /> <Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
</button> </button>
@ -326,7 +330,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
type="text" type="text"
placeholder="https://the.link.to/the/image.png" placeholder="https://the.link.to/the/image.png"
size="sm" size="sm"
value={state.shortcutCreate.openGraphMetadata.image} value={state.shortcutCreate.ogMetadata?.image}
onChange={handleOpenGraphMetadataImageChange} onChange={handleOpenGraphMetadataImageChange}
/> />
</div> </div>
@ -335,9 +339,9 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform" placeholder="Slash - An open source, self-hosted links shortener and sharing platform"
size="sm" size="sm"
value={state.shortcutCreate.openGraphMetadata.title} value={state.shortcutCreate.ogMetadata?.title}
onChange={handleOpenGraphMetadataTitleChange} onChange={handleOpenGraphMetadataTitleChange}
/> />
</div> </div>
@ -345,29 +349,30 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
<span className="mb-2 text-sm">Description</span> <span className="mb-2 text-sm">Description</span>
<Textarea <Textarea
className="w-full" className="w-full"
placeholder="An open source, self-hosted bookmarks and link sharing platform." placeholder="An open source, self-hosted links shortener and sharing platform."
size="sm" size="sm"
maxRows={3} maxRows={3}
value={state.shortcutCreate.openGraphMetadata.description} value={state.shortcutCreate.ogMetadata?.description}
onChange={handleOpenGraphMetadataDescriptionChange} onChange={handleOpenGraphMetadataDescriptionChange}
/> />
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.save")}
</Button>
</div>
</div> </div>
</ModalDialog> </DialogContent>
</Modal> <DialogActions>
<div className="w-full flex flex-row justify-end items-center px-3 py-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.save")}
</Button>
</div>
</DialogActions>
</Drawer>
); );
}; };
export default CreateShortcutDialog; export default CreateShortcutDrawer;

View File

@ -3,8 +3,9 @@ import { isUndefined } from "lodash-es";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLoading from "../hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useUserStore from "../stores/v1/user"; import { useUserStore } from "@/stores";
import { Role, User } from "@/types/proto/api/v1/user_service";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -14,11 +15,9 @@ interface Props {
} }
interface State { interface State {
userCreate: UserCreate; userCreate: Pick<User, "email" | "nickname" | "password" | "role">;
} }
const roles: Role[] = ["USER", "ADMIN"];
const CreateUserDialog: React.FC<Props> = (props: Props) => { const CreateUserDialog: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, user } = props; const { onClose, onConfirm, user } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@ -28,7 +27,7 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
email: "", email: "",
nickname: "", nickname: "",
password: "", password: "",
role: "USER", role: Role.USER,
}, },
}); });
const requestState = useLoading(false); const requestState = useLoading(false);
@ -95,7 +94,7 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
try { try {
if (user) { if (user) {
const userPatch: UserPatch = { const userPatch: Partial<User> = {
id: user.id, id: user.id,
}; };
if (user.email !== state.userCreate.email) { if (user.email !== state.userCreate.email) {
@ -119,14 +118,14 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
} }
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
}; };
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4"> <div className="flex flex-row justify-between items-center w-80 sm:w-96">
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span> <span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
<Button variant="plain" onClick={onClose}> <Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />
@ -179,9 +178,8 @@ const CreateUserDialog: React.FC<Props> = (props: Props) => {
</span> </span>
<div className="w-full flex flex-row justify-start items-center text-base"> <div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}> <RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
{roles.map((role) => ( <Radio value={Role.USER} label={"User"} />
<Radio key={role} value={role} label={role} /> <Radio value={Role.ADMIN} label={"Admin"} />
))}
</RadioGroup> </RadioGroup>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import useWorkspaceStore from "@/stores/v1/workspace"; import { useWorkspaceStore } from "@/stores";
import Icon from "./Icon"; import Icon from "./Icon";
const DemoBanner: React.FC = () => { const DemoBanner: React.FC = () => {
@ -9,11 +9,11 @@ const DemoBanner: React.FC = () => {
return ( return (
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow"> <div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
<div className="w-full max-w-6xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3"> <div className="w-full max-w-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
<span>🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span> <span>🔗 Slash - An open source, self-hosted links shortener and sharing platform</span>
<a <a
className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700" className="shadow flex flex-row justify-center items-center px-2 py-1 rounded-md text-sm sm:text-base text-white bg-blue-600 hover:bg-blue-700"
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds" href="https://github.com/yourselfhosted/slash#deploy-with-docker-in-seconds"
target="_blank" target="_blank"
> >
Install Install

View File

@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLoading from "../hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useUserStore from "../stores/v1/user"; import { useUserStore } from "@/stores";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -50,7 +50,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
toast("User information updated"); toast("User information updated");
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
requestState.setFinish(); requestState.setFinish();
}; };
@ -58,7 +58,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">Edit Userinfo</span> <span className="text-lg font-medium">Edit Userinfo</span>
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useViewStore from "../stores/v1/view"; import { useViewStore } from "@/stores";
import { convertVisibilityFromPb } from "@/utils/visibility";
import Icon from "./Icon"; import Icon from "./Icon";
import VisibilityIcon from "./VisibilityIcon"; import VisibilityIcon from "./VisibilityIcon";
@ -32,7 +33,7 @@ const FilterView = () => {
onClick={() => viewStore.setFilter({ visibility: undefined })} onClick={() => viewStore.setFilter({ visibility: undefined })}
> >
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} /> <VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)} {t(`shortcut.visibility.${convertVisibilityFromPb(filter.visibility).toLowerCase()}.self`)}
<Icon.X className="w-4 h-auto ml-1" /> <Icon.X className="w-4 h-auto ml-1" />
</button> </button>
)} )}

View File

@ -3,7 +3,8 @@ import { QRCodeCanvas } from "qrcode.react";
import { useRef } from "react"; import { useRef } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { absolutifyLink } from "../helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -24,12 +25,12 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
const handleDownloadQRCodeClick = () => { const handleDownloadQRCodeClick = () => {
const canvas = containerRef.current?.querySelector("canvas"); const canvas = containerRef.current?.querySelector("canvas");
if (!canvas) { if (!canvas) {
toast.error("Failed to get qr code canvas"); toast.error("Failed to get QR code canvas");
return; return;
} }
const link = document.createElement("a"); const link = document.createElement("a");
link.download = "filename.png"; link.download = `${shortcut.title || shortcut.name}-qrcode.png`;
link.href = canvas.toDataURL(); link.href = canvas.toDataURL();
link.click(); link.click();
handleCloseBtnClick(); handleCloseBtnClick();
@ -38,7 +39,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-64 mb-4"> <div className="flex flex-row justify-between items-center w-64">
<span className="text-lg font-medium">QR Code</span> <span className="text-lg font-medium">QR Code</span>
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />
@ -46,7 +47,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
</div> </div>
<div> <div>
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6"> <div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} /> <QRCodeCanvas value={shortcutLink} size={180} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
</div> </div>
<div className="w-full flex flex-row justify-center items-center px-4"> <div className="w-full flex flex-row justify-center items-center px-4">
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}> <Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>

View File

@ -1,35 +1,38 @@
import { Avatar } from "@mui/joy"; import { Avatar } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import useWorkspaceStore from "@/stores/v1/workspace"; import { authServiceClient } from "@/grpcweb";
import { PlanType } from "@/types/proto/api/v2/subscription_service"; import { useWorkspaceStore, useUserStore } from "@/stores";
import * as api from "../helpers/api"; import { PlanType } from "@/types/proto/api/v1/subscription_service";
import useUserStore from "../stores/v1/user"; import { Role } from "@/types/proto/api/v1/user_service";
import AboutDialog from "./AboutDialog"; import AboutDialog from "./AboutDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
const Header: React.FC = () => { const Header: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation();
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false); const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
const profile = workspaceStore.profile; const profile = workspaceStore.profile;
const isAdmin = currentUser.role === "ADMIN"; const isAdmin = currentUser.role === Role.ADMIN;
const shouldShowRouterSwitch = location.pathname === "/shortcuts" || location.pathname === "/collections";
const selectedSection = location.pathname === "/shortcuts" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "";
const handleSignOutButtonClick = async () => { const handleSignOutButtonClick = async () => {
await api.signout(); await authServiceClient.signOut({});
window.location.href = "/auth"; window.location.href = "/auth";
}; };
return ( return (
<> <>
<div className="w-full bg-gray-50 dark:bg-zinc-900 border-b border-b-gray-200 dark:border-b-zinc-800"> <div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center"> <div className="w-full max-w-8xl mx-auto px-4 sm:px-6 md:px-12 py-3 flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center shrink mr-2"> <div className="flex flex-row justify-start items-center shrink mr-2">
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400"> <Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" unstable_viewTransition>
<img id="logo-img" src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5 dark:opacity-80" alt="" /> <Icon.CircleSlash className="w-7 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} />
Slash Slash
</Link> </Link>
{profile.plan === PlanType.PRO && ( {profile.plan === PlanType.PRO && (
@ -37,6 +40,38 @@ const Header: React.FC = () => {
PRO PRO
</span> </span>
)} )}
{shouldShowRouterSwitch && (
<>
<span className="font-mono opacity-60 mx-1 dark:text-gray-400">/</span>
<Dropdown
trigger={
<button className="flex flex-row justify-end items-center cursor-pointer">
<span className="dark:text-gray-400">{selectedSection}</span>
<Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" />
</button>
}
actionsClassName="!w-36 -left-4"
actions={
<>
<Link
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
to="/shortcuts"
unstable_viewTransition
>
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
</Link>
<Link
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
to="/collections"
unstable_viewTransition
>
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections
</Link>
</>
}
></Dropdown>
</>
)}
</div> </div>
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<Dropdown <Dropdown
@ -51,30 +86,32 @@ const Header: React.FC = () => {
actions={ actions={
<> <>
<Link <Link
to="/setting/general"
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60" className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
to="/setting/general"
unstable_viewTransition
> >
<Icon.User className="w-4 h-auto mr-2" /> {t("user.profile")} <Icon.User className="w-5 h-auto mr-2 opacity-70" /> {t("user.profile")}
</Link> </Link>
{isAdmin && ( {isAdmin && (
<Link <Link
to="/setting/workspace"
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60" className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
to="/setting/workspace"
unstable_viewTransition
> >
<Icon.Settings className="w-4 h-auto mr-2" /> {t("settings.self")} <Icon.Settings className="w-5 h-auto mr-2 opacity-70" /> {t("settings.self")}
</Link> </Link>
)} )}
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60" className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => setShowAboutDialog(true)} onClick={() => setShowAboutDialog(true)}
> >
<Icon.Info className="w-4 h-auto mr-2" /> {t("common.about")} <Icon.Info className="w-5 h-auto mr-2 opacity-70" /> {t("common.about")}
</button> </button>
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60" className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
onClick={() => handleSignOutButtonClick()} onClick={() => handleSignOutButtonClick()}
> >
<Icon.LogOut className="w-4 h-auto mr-2" /> {t("auth.sign-out")} <Icon.LogOut className="w-5 h-auto mr-2 opacity-70" /> {t("auth.sign-out")}
</button> </button>
</> </>
} }

View File

@ -0,0 +1,36 @@
import { useState } from "react";
import { useWorkspaceStore } from "@/stores";
import Icon from "./Icon";
interface Props {
url: string;
}
const getFaviconUrlWithProvider = (url: string, provider: string) => {
try {
const searchParams = new URLSearchParams();
searchParams.set("domain", new URL(url).hostname);
return new URL(`?${searchParams.toString()}`, provider).toString();
} catch (error) {
return "";
}
};
const LinkFavicon = (props: Props) => {
const { url } = props;
const workspaceStore = useWorkspaceStore();
const faviconProvider = workspaceStore.profile.faviconProvider || "https://www.google.com/s2/favicons";
const [faviconUrl, setFaviconUrl] = useState<string>(getFaviconUrlWithProvider(url, faviconProvider));
const handleImgError = () => {
setFaviconUrl("");
};
return faviconUrl ? (
<img className="w-full h-auto rounded" src={faviconUrl} decoding="async" loading="lazy" onError={handleImgError} />
) : (
<Icon.CircleSlash className="w-full h-auto text-gray-400" strokeWidth={1.5} />
);
};
export default LinkFavicon;

View File

@ -1,70 +1,5 @@
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../stores";
import useViewStore from "../stores/v1/view";
import Icon from "./Icon";
const Navigator = () => { const Navigator = () => {
const { t } = useTranslation(); return <></>;
const viewStore = useViewStore();
const { shortcutList } = useAppSelector((state) => state.shortcut);
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
const currentTab = viewStore.filter.tab || `tab:all`;
const sortedTagMap = sortTags(tags);
return (
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:all"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
>
<Icon.CircleSlash className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.all")}</span>
</button>
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:mine"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
>
<Icon.User className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.mine")}</span>
</button>
{Array.from(sortedTagMap.keys()).map((tag) => (
<button
key={tag}
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === `tag:${tag}`
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
>
<Icon.Hash className="w-4 h-auto mr-0.5" />
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
</button>
))}
</div>
);
};
const sortTags = (tags: string[]): Map<string, number> => {
const map = new Map<string, number>();
for (const tag of tags) {
const count = map.get(tag) || 0;
map.set(tag, count + 1);
}
const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1]));
return sortedMap;
}; };
export default Navigator; export default Navigator;

View File

@ -0,0 +1,59 @@
import { IconButton, Input } from "@mui/joy";
import classNames from "classnames";
import { useEffect, useState } from "react";
import { generateRandomString } from "@/helpers/utils";
import Icon from "./Icon";
interface Props {
name: string;
onChange: (name: string) => void;
}
const ResourceNameInput = (props: Props) => {
const { name, onChange } = props;
const [modified, setModified] = useState(false);
const [editingName, setEditingName] = useState(name || generateRandomString().toLowerCase());
useEffect(() => {
onChange(editingName);
}, [editingName]);
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!modified) {
return;
}
setEditingName(e.target.value);
};
return (
<div className="w-full flex flex-col justify-start items-start mb-3">
<div className={classNames("", modified ? "mb-2" : "flex flex-row justify-start items-center")}>
<span>Name</span>
{modified ? (
<span className="text-red-600"> *</span>
) : (
<>
<span>:</span>
<span className="ml-1 font-mono font-medium">{editingName}</span>
<div className="ml-1 flex flex-row justify-start items-center">
<IconButton size="sm" variant="plain" color="neutral" onClick={() => setModified(true)}>
<Icon.Edit className="w-4 h-auto text-gray-500 dark:text-gray-400" />
</IconButton>
<IconButton size="sm" variant="plain" color="neutral" onClick={() => setEditingName(generateRandomString().toLowerCase())}>
<Icon.RefreshCcw className="w-4 h-auto text-gray-500 dark:text-gray-400" />
</IconButton>
</div>
</>
)}
</div>
{modified && (
<div className="relative w-full">
<Input className="w-full" type="text" placeholder="An unique name" value={editingName} onChange={handleNameInputChange} />
</div>
)}
</div>
);
};
export default ResourceNameInput;

View File

@ -1,10 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { shortcutService } from "../services"; import { useShortcutStore, useUserStore } from "@/stores";
import useUserStore from "../stores/v1/user"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { Role } from "@/types/proto/api/v1/user_service";
import { showCommonDialog } from "./Alert"; import { showCommonDialog } from "./Alert";
import CreateShortcutDialog from "./CreateShortcutDialog"; import CreateShortcutDrawer from "./CreateShortcutDrawer";
import GenerateQRCodeDialog from "./GenerateQRCodeDialog"; import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
@ -17,10 +18,11 @@ const ShortcutActionsDropdown = (props: Props) => {
const { shortcut } = props; const { shortcut } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const shortcutStore = useShortcutStore();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const [showEditDialog, setShowEditDialog] = useState<boolean>(false); const [showEditDrawer, setShowEditDrawer] = useState<boolean>(false);
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false); const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id; const havePermission = currentUser.role === Role.ADMIN || shortcut.creatorId === currentUser.id;
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => { const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
showCommonDialog({ showCommonDialog({
@ -28,7 +30,7 @@ const ShortcutActionsDropdown = (props: Props) => {
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`, content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
style: "danger", style: "danger",
onConfirm: async () => { onConfirm: async () => {
await shortcutService.deleteShortcutById(shortcut.id); await shortcutStore.deleteShortcut(shortcut.id);
}, },
}); });
}; };
@ -40,28 +42,28 @@ const ShortcutActionsDropdown = (props: Props) => {
return ( return (
<> <>
<Dropdown <Dropdown
actionsClassName="!w-32 dark:text-gray-500" actionsClassName="!w-32 dark:text-gray-500 text-sm"
actions={ actions={
<> <>
{havePermission && ( {havePermission && (
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800" className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => setShowEditDialog(true)} onClick={() => setShowEditDrawer(true)}
> >
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")} <Icon.Edit className="w-4 h-auto mr-2 opacity-70" /> {t("common.edit")}
</button> </button>
)} )}
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800" className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => setShowQRCodeDialog(true)} onClick={() => setShowQRCodeDialog(true)}
> >
<Icon.QrCode className="w-4 h-auto mr-2" /> QR Code <Icon.QrCode className="w-4 h-auto mr-2 opacity-70" /> QR Code
</button> </button>
<button <button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800" className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={gotoAnalytics} onClick={gotoAnalytics}
> >
<Icon.BarChart2 className="w-4 h-auto mr-2" /> {t("analytics.self")} <Icon.BarChart2 className="w-4 h-auto mr-2 opacity-70" /> {t("analytics.self")}
</button> </button>
{havePermission && ( {havePermission && (
<button <button
@ -70,18 +72,18 @@ const ShortcutActionsDropdown = (props: Props) => {
handleDeleteShortcutButtonClick(shortcut); handleDeleteShortcutButtonClick(shortcut);
}} }}
> >
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")} <Icon.Trash className="w-4 h-auto mr-2 opacity-70" /> {t("common.delete")}
</button> </button>
)} )}
</> </>
} }
></Dropdown> ></Dropdown>
{showEditDialog && ( {showEditDrawer && (
<CreateShortcutDialog <CreateShortcutDrawer
shortcutId={shortcut.id} shortcutId={shortcut.id}
onClose={() => setShowEditDialog(false)} onClose={() => setShowEditDrawer(false)}
onConfirm={() => setShowEditDialog(false)} onConfirm={() => setShowEditDrawer(false)}
/> />
)} )}

View File

@ -1,12 +1,16 @@
import { Tooltip } from "@mui/joy"; import { Avatar, Tooltip } from "@mui/joy";
import classNames from "classnames"; import classNames from "classnames";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useEffect } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useViewStore from "../stores/v1/view"; import { useUserStore, useViewStore } from "@/stores";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import { convertVisibilityFromPb } from "@/utils/visibility";
import Icon from "./Icon"; import Icon from "./Icon";
import LinkFavicon from "./LinkFavicon";
import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
import VisibilityIcon from "./VisibilityIcon"; import VisibilityIcon from "./VisibilityIcon";
@ -14,12 +18,17 @@ interface Props {
shortcut: Shortcut; shortcut: Shortcut;
} }
const ShortcutView = (props: Props) => { const ShortcutCard = (props: Props) => {
const { shortcut } = props; const { shortcut } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const userStore = useUserStore();
const viewStore = useViewStore(); const viewStore = useViewStore();
const creator = userStore.getUserById(shortcut.creatorId);
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
const favicon = getFaviconWithGoogleS2(shortcut.link);
useEffect(() => {
userStore.getOrFetchUserById(shortcut.creatorId);
}, []);
const handleCopyButtonClick = () => { const handleCopyButtonClick = () => {
copy(shortcutLink); copy(shortcutLink);
@ -27,110 +36,114 @@ const ShortcutView = (props: Props) => {
}; };
return ( return (
<> <div
<div className={classNames(
className={classNames( "group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-700"
"group px-4 py-3 w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-700" )}
)} >
> <div className="w-full flex flex-row justify-between items-center">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0">
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0"> <Link
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}> className={classNames("w-8 h-8 flex justify-center items-center overflow-clip shrink-0")}
{favicon ? ( to={`/shortcut/${shortcut.id}`}
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" /> unstable_viewTransition
) : ( >
<Icon.CircleSlash className="w-full h-auto text-gray-400" /> <LinkFavicon url={shortcut.link} />
)} </Link>
</Link> <div className="ml-2 w-[calc(100%-24px)] flex flex-col justify-start items-start">
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start"> <div className="w-full flex flex-row justify-start items-center leading-tight">
<div className="w-full flex flex-row justify-start items-center">
<a
className={classNames(
"max-w-[calc(100%-36px)] flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
)}
target="_blank"
href={shortcutLink}
>
<div className="truncate">
<span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? (
<span className="text-gray-500">(s/{shortcut.name})</span>
) : (
<>
<span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
</>
)}
</div>
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span>
</a>
<Tooltip title="Copy" variant="solid" placement="top" arrow>
<button
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
onClick={() => handleCopyButtonClick()}
>
<Icon.Clipboard className="w-4 h-auto mx-auto" />
</button>
</Tooltip>
</div>
<a <a
className="pl-1 pr-4 w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline" className={classNames(
href={shortcut.link} "max-w-[calc(100%-36px)] flex flex-row justify-start items-center mr-1 cursor-pointer hover:opacity-80 hover:underline"
)}
target="_blank" target="_blank"
href={shortcutLink}
> >
{shortcut.link} <div className="truncate">
<span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? (
<span className="text-gray-500">({shortcut.name})</span>
) : (
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
)}
</div>
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span>
</a> </a>
<Tooltip title="Copy" variant="solid" placement="top" arrow>
<button
className="hidden group-hover:block cursor-pointer text-gray-500 hover:opacity-80"
onClick={() => handleCopyButtonClick()}
>
<Icon.Clipboard className="w-4 h-auto mx-auto" />
</button>
</Tooltip>
</div> </div>
</div> <a
<div className="h-full pt-2 flex flex-row justify-end items-start"> className="pr-4 leading-tight w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
<ShortcutActionsDropdown shortcut={shortcut} /> href={shortcut.link}
target="_blank"
>
{shortcut.link}
</a>
</div> </div>
</div> </div>
<div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate"> <div className="h-full pt-2 flex flex-row justify-end items-start">
{shortcut.tags.map((tag) => { <ShortcutActionsDropdown shortcut={shortcut} />
return (
<span
key={tag}
className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm font-mono leading-4 cursor-pointer hover:opacity-80"
onClick={() => viewStore.setFilter({ tag: tag })}
>
#{tag}
</span>
);
})}
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
</div>
<div className="w-full flex mt-2 gap-2 overflow-x-auto">
<Tooltip title="Creator" variant="solid" placement="top" arrow>
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowra whitespace-nowrap border rounded-full text-gray-500 text-sm dark:border-zinc-800">
<Icon.User className="w-4 h-auto mr-1" />
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
</div>
</Tooltip>
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
<div
className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 text-sm dark:border-zinc-800"
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
>
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
</div>
</Tooltip>
<Tooltip title="View count" variant="solid" placement="top" arrow>
<Link
to={`/shortcut/${shortcut.id}#analytics`}
className="w-auto px-2 leading-6 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap border rounded-full cursor-pointer text-gray-500 text-sm dark:border-zinc-800"
>
<Icon.BarChart2 className="w-4 h-auto mr-1" />
{t("shortcut.visits", { count: shortcut.view })}
</Link>
</Tooltip>
</div> </div>
</div> </div>
</> <div className="mt-2 w-full flex flex-row justify-start items-start gap-2 truncate">
{shortcut.tags.map((tag) => {
return (
<span
key={tag}
className="max-w-[8rem] truncate text-gray-400 dark:text-gray-500 text-sm leading-4 cursor-pointer hover:opacity-80"
onClick={() => viewStore.setFilter({ tag: tag })}
>
#{tag}
</span>
);
})}
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm leading-4 italic">No tags</span>}
</div>
<div className="w-full mt-2 flex gap-2 overflow-x-auto">
<Tooltip title={creator.nickname} variant="solid" placement="top" arrow>
<Avatar
className="dark:bg-zinc-800"
sx={{
"--Avatar-size": "24px",
}}
alt={creator.nickname.toUpperCase()}
></Avatar>
</Tooltip>
<Tooltip
title={t(`shortcut.visibility.${convertVisibilityFromPb(shortcut.visibility).toLowerCase()}.description`)}
variant="solid"
placement="top"
arrow
>
<div
className="w-auto leading-5 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap cursor-pointer text-gray-400 text-sm"
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
>
<VisibilityIcon className="w-4 h-auto mr-1 opacity-70" visibility={shortcut.visibility} />
{t(`shortcut.visibility.${convertVisibilityFromPb(shortcut.visibility).toLowerCase()}.self`)}
</div>
</Tooltip>
<Tooltip title="View count" variant="solid" placement="top" arrow>
<Link
className="w-auto leading-5 flex flex-row justify-start items-center flex-nowrap whitespace-nowrap cursor-pointer text-gray-400 text-sm"
to={`/shortcut/${shortcut.id}#analytics`}
unstable_viewTransition
>
<Icon.BarChart2 className="w-4 h-auto mr-1 opacity-70" />
{t("shortcut.visits", { count: shortcut.viewCount })}
</Link>
</Tooltip>
</div>
</div>
); );
}; };
export default ShortcutView; export default ShortcutCard;

View File

@ -0,0 +1,35 @@
import { Divider } from "@mui/joy";
import classNames from "classnames";
import { Link } from "react-router-dom";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon";
import LinkFavicon from "./LinkFavicon";
interface Props {
shortcut: Shortcut;
}
const ShortcutFrame = ({ shortcut }: Props) => {
return (
<div className="w-full h-full flex flex-col justify-center items-center p-8">
<Link
className="w-72 max-w-full border dark:border-zinc-900 dark:bg-zinc-900 p-6 pb-4 rounded-2xl shadow-xl dark:text-gray-400 hover:opacity-80"
to={`/s/${shortcut.name}`}
target="_blank"
>
<div className={classNames("w-12 h-12 flex justify-center items-center overflow-clip rounded-lg shrink-0")}>
<LinkFavicon url={shortcut.link} />
</div>
<p className="text-lg font-medium leading-8 mt-2 truncate">{shortcut.title || shortcut.name}</p>
<p className="text-gray-500 truncate">{shortcut.description}</p>
<Divider className="!my-2" />
<p className="text-gray-400 dark:text-gray-600 text-sm mt-2">
<span className="leading-4">Open this site in a new tab</span>
<Icon.ArrowUpRight className="inline-block ml-1 -mt-0.5 w-4 h-auto" />
</p>
</Link>
</div>
);
};
export default ShortcutFrame;

View File

@ -1,67 +1,61 @@
import classNames from "classnames"; import classNames from "classnames";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import Icon from "./Icon"; import Icon from "./Icon";
import LinkFavicon from "./LinkFavicon";
import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
interface Props { interface Props {
shortcut: Shortcut; shortcut: Shortcut;
className?: string;
showActions?: boolean;
alwaysShowLink?: boolean;
onClick?: () => void;
} }
const ShortcutView = (props: Props) => { const ShortcutView = (props: Props) => {
const { shortcut } = props; const { shortcut, className, showActions, alwaysShowLink, onClick } = props;
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
const favicon = getFaviconWithGoogleS2(shortcut.link);
return ( return (
<> <div
<div className={classNames(
className={classNames( "group w-full px-3 py-2 flex flex-row justify-start items-center border rounded-lg hover:bg-gray-100 dark:border-zinc-800 dark:hover:bg-zinc-800",
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800" className
)} )}
> onClick={onClick}
<div className="w-full flex flex-row justify-between items-center"> >
<div className="w-[calc(100%-16px)] flex flex-row justify-start items-center mr-1 shrink-0"> <div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}> <LinkFavicon url={shortcut.link} />
{favicon ? (
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
) : (
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
)}
</Link>
<div className="ml-1 w-[calc(100%-20px)] flex flex-col justify-start items-start">
<div className="w-full flex flex-row justify-start items-center">
<a
className={classNames(
"max-w-full flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:underline"
)}
href={shortcutLink}
target="_blank"
>
<div className="truncate">
<span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? (
<span className="text-gray-500">(s/{shortcut.name})</span>
) : (
<>
<span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
</>
)}
</div>
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
</span>
</a>
</div>
</div>
</div>
<div className="flex flex-row justify-end items-center">
<ShortcutActionsDropdown shortcut={shortcut} />
</div>
</div>
</div> </div>
</> <div className="ml-2 w-full truncate">
{shortcut.title ? (
<>
<span className="dark:text-gray-400">{shortcut.title}</span>
<span className="text-gray-500">({shortcut.name})</span>
</>
) : (
<>
<span className="dark:text-gray-400">{shortcut.name}</span>
</>
)}
</div>
<Link
className={classNames(
"hidden group-hover:block ml-1 w-6 h-6 p-1 shrink-0 rounded-lg bg-gray-200 dark:bg-zinc-900 hover:opacity-80",
alwaysShowLink && "!block"
)}
to={`/s/${shortcut.name}`}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
<Icon.ArrowUpRight className="w-4 h-auto text-gray-400 shrink-0" />
</Link>
{showActions && (
<div className="ml-1 flex flex-row justify-end items-center shrink-0" onClick={(e) => e.stopPropagation()}>
<ShortcutActionsDropdown shortcut={shortcut} />
</div>
)}
</div>
); );
}; };

View File

@ -1,5 +1,7 @@
import classNames from "classnames"; import classNames from "classnames";
import useViewStore from "../stores/v1/view"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useViewStore } from "@/stores";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import ShortcutCard from "./ShortcutCard"; import ShortcutCard from "./ShortcutCard";
import ShortcutView from "./ShortcutView"; import ShortcutView from "./ShortcutView";
@ -9,19 +11,24 @@ interface Props {
const ShortcutsContainer: React.FC<Props> = (props: Props) => { const ShortcutsContainer: React.FC<Props> = (props: Props) => {
const { shortcutList } = props; const { shortcutList } = props;
const navigateTo = useNavigateTo();
const viewStore = useViewStore(); const viewStore = useViewStore();
const displayStyle = viewStore.displayStyle || "full"; const displayStyle = viewStore.displayStyle || "full";
const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard; const ShortcutItemView = viewStore.displayStyle === "compact" ? ShortcutView : ShortcutCard;
const handleShortcutClick = (shortcut: Shortcut) => {
navigateTo(`/shortcut/${shortcut.id}`);
};
return ( return (
<div <div
className={classNames( className={classNames(
"w-full grid grid-cols-1 gap-3 sm:gap-4", "w-full grid grid-cols-1 gap-3 sm:gap-4",
displayStyle === "full" ? "sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-2 sm:grid-cols-4" displayStyle === "full" ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" : "grid-cols-2 sm:grid-cols-4"
)} )}
> >
{shortcutList.map((shortcut) => { {shortcutList.map((shortcut) => {
return <ShortcutItemView key={shortcut.id} shortcut={shortcut} />; return <ShortcutItemView key={shortcut.id} shortcut={shortcut} showActions={true} onClick={() => handleShortcutClick(shortcut)} />;
})} })}
</div> </div>
); );

View File

@ -0,0 +1,69 @@
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { useShortcutStore, useViewStore } from "@/stores";
import Icon from "./Icon";
const ShortcutsNavigator = () => {
const { t } = useTranslation();
const viewStore = useViewStore();
const shortcutList = useShortcutStore().getShortcutList();
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
const currentTab = viewStore.filter.tab || `tab:all`;
const sortedTagMap = sortTags(tags);
return (
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:all"
? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
>
<Icon.CircleSlash className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.all")}</span>
</button>
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:mine"
? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
>
<Icon.User className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.mine")}</span>
</button>
{Array.from(sortedTagMap.keys()).map((tag) => (
<button
key={tag}
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === `tag:${tag}`
? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
>
<Icon.Hash className="w-4 h-auto mr-0.5" />
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
</button>
))}
</div>
);
};
const sortTags = (tags: string[]): Map<string, number> => {
const map = new Map<string, number>();
for (const tag of tags) {
const count = map.get(tag) || 0;
map.set(tag, count + 1);
}
const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1]));
return sortedMap;
};
export default ShortcutsNavigator;

View File

@ -11,18 +11,25 @@ const SubscriptionFAQ = () => {
<Accordion> <Accordion>
<AccordionSummary>Can I use the Free plan in my team?</AccordionSummary> <AccordionSummary>Can I use the Free plan in my team?</AccordionSummary>
<AccordionDetails> <AccordionDetails>
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you can upgrade to the Pro Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you should upgrade to the
plan. Pro plan.
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
<Accordion> <Accordion>
<AccordionSummary>How many devices can the license key be used on?</AccordionSummary> <AccordionSummary>How many devices can the license key be used on?</AccordionSummary>
<AccordionDetails>{`It's unlimited for now, but please don't abuse it.`}</AccordionDetails> <AccordionDetails>{`It's unlimited for now, but please do not abuse it.`}</AccordionDetails>
</Accordion> </Accordion>
<Accordion> <Accordion>
<AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary> <AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary>
<AccordionDetails> <AccordionDetails>
Yes, absolutely! You can send a email to me at `stevenlgtm@gmail.com`. I will refund you as soon as possible. Yes, absolutely! You can contact us with `yourselfhosted@gmail.com`. I will refund you as soon as possible.
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary>Is there a Lifetime license?</AccordionSummary>
<AccordionDetails>
{`As software requires someone to maintain it, so we won't sell a lifetime service, since humans are not immortal yet. But if you
really want it, please contact us "yourselfhosted@gmail.com".`}
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
</AccordionGroup> </AccordionGroup>

View File

@ -1,6 +1,6 @@
import { Divider, Option, Select, Switch } from "@mui/joy"; import { Divider, Option, Select, Switch } from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useViewStore from "../stores/v1/view"; import { useViewStore } from "@/stores";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
@ -18,7 +18,7 @@ const ViewSetting = () => {
<Icon.Settings2 className="w-4 h-auto text-gray-500" /> <Icon.Settings2 className="w-4 h-auto text-gray-500" />
</button> </button>
} }
actionsClassName="!mt-3 !-right-2" actionsClassName="!mt-3 !right-[unset] -left-24 -ml-2"
actions={ actions={
<div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}> <div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">

View File

@ -1,3 +1,4 @@
import { Visibility } from "@/types/proto/api/v1/common";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -7,11 +8,11 @@ interface Props {
const VisibilityIcon = (props: Props) => { const VisibilityIcon = (props: Props) => {
const { visibility, className } = props; const { visibility, className } = props;
if (visibility === "PRIVATE") { if (visibility === Visibility.PRIVATE) {
return <Icon.Lock className={className || ""} />; return <Icon.Lock className={className || ""} />;
} else if (visibility === "WORKSPACE") { } else if (visibility === Visibility.WORKSPACE) {
return <Icon.Building2 className={className || ""} />; return <Icon.Building2 className={className || ""} />;
} else if (visibility === "PUBLIC") { } else if (visibility === Visibility.PUBLIC) {
return <Icon.Globe2 className={className || ""} />; return <Icon.Globe2 className={className || ""} />;
} }
return null; return null;

View File

@ -1,5 +1,5 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import Icon from "../Icon"; import Icon from "@/components/Icon";
interface Props { interface Props {
trigger?: ReactNode; trigger?: ReactNode;

View File

@ -3,12 +3,12 @@ import copy from "copy-to-clipboard";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showCommonDialog } from "@/components/Alert";
import CreateAccessTokenDialog from "@/components/CreateAccessTokenDialog";
import Icon from "@/components/Icon";
import { userServiceClient } from "@/grpcweb"; import { userServiceClient } from "@/grpcweb";
import { UserAccessToken } from "@/types/proto/api/v2/user_service"; import { useUserStore } from "@/stores";
import useUserStore from "../../stores/v1/user"; import { UserAccessToken } from "@/types/proto/api/v1/user_service";
import { showCommonDialog } from "../Alert";
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import Icon from "../Icon";
const listAccessTokens = async (userId: number) => { const listAccessTokens = async (userId: number) => {
const { accessTokens } = await userServiceClient.listUserAccessTokens({ const { accessTokens } = await userServiceClient.listUserAccessTokens({
@ -64,7 +64,7 @@ const AccessTokenSection = () => {
<div className="w-full"> <div className="w-full">
<div className="sm:flex sm:items-center"> <div className="sm:flex sm:items-center">
<div className="sm:flex-auto"> <div className="sm:flex-auto">
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Access Tokens</p> <p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">Access Tokens</p>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">A list of all access tokens for your account.</p> <p className="mt-2 text-sm text-gray-700 dark:text-gray-600">A list of all access tokens for your account.</p>
</div> </div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">

View File

@ -1,21 +1,22 @@
import { Button } from "@mui/joy"; import { Button } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useUserStore from "../../stores/v1/user"; import ChangePasswordDialog from "@/components/ChangePasswordDialog";
import ChangePasswordDialog from "../ChangePasswordDialog"; import EditUserinfoDialog from "@/components/EditUserinfoDialog";
import EditUserinfoDialog from "../EditUserinfoDialog"; import { useUserStore } from "@/stores";
import { Role } from "@/types/proto/api/v1/user_service";
const AccountSection: React.FC = () => { const AccountSection: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false); const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false); const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
const isAdmin = currentUser.role === "ADMIN"; const isAdmin = currentUser.role === Role.ADMIN;
return ( return (
<> <>
<div className="w-full flex flex-col justify-start items-start gap-y-2"> <div className="w-full flex flex-col justify-start items-start gap-y-2">
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("common.account")}</p> <p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("common.account")}</p>
<p className="flex flex-row justify-start items-center mt-2 dark:text-gray-400"> <p className="flex flex-row justify-start items-center mt-2 dark:text-gray-400">
<span className="text-xl">{currentUser.nickname}</span> <span className="text-xl">{currentUser.nickname}</span>
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>} {isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}

View File

@ -2,10 +2,12 @@ import { Button, IconButton } from "@mui/joy";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useUserStore from "../../stores/v1/user"; import { showCommonDialog } from "@/components/Alert";
import { showCommonDialog } from "../Alert"; import CreateUserDialog from "@/components/CreateUserDialog";
import CreateUserDialog from "../CreateUserDialog"; import Icon from "@/components/Icon";
import Icon from "../Icon"; import { useUserStore } from "@/stores";
import { User } from "@/types/proto/api/v1/user_service";
import { convertRoleFromPb } from "@/utils/user";
const MemberSection = () => { const MemberSection = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -33,7 +35,7 @@ const MemberSection = () => {
await userStore.deleteUser(user.id); await userStore.deleteUser(user.id);
toast.success(`User \`${user.nickname}\` deleted successfully`); toast.success(`User \`${user.nickname}\` deleted successfully`);
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to delete user \`${user.nickname}\`: ${error.response.data.message}`); toast.error(`Failed to delete user \`${user.nickname}\`: ${error.details}`);
} }
}, },
}); });
@ -45,7 +47,7 @@ const MemberSection = () => {
<div className="w-full"> <div className="w-full">
<div className="sm:flex sm:items-center"> <div className="sm:flex sm:items-center">
<div className="sm:flex-auto"> <div className="sm:flex-auto">
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("user.self")}</p> <p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("user.self")}</p>
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600"> <p className="mt-2 text-sm text-gray-700 dark:text-gray-600">
A list of all the users in your workspace including their nickname, email and role. A list of all the users in your workspace including their nickname, email and role.
</p> </p>
@ -88,7 +90,7 @@ const MemberSection = () => {
<tr key={user.email}> <tr key={user.email}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">{user.nickname}</td> <td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-500">{user.nickname}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td> <td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{convertRoleFromPb(user.role)}</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm"> <td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm">
<IconButton <IconButton
size="sm" size="sm"

View File

@ -1,8 +1,8 @@
import { Option, Select } from "@mui/joy"; import { Option, Select } from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service"; import BetaBadge from "@/components/BetaBadge";
import useUserStore from "../../stores/v1/user"; import { useUserStore } from "@/stores";
import BetaBadge from "../BetaBadge"; import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v1/user_setting_service";
const PreferenceSection: React.FC = () => { const PreferenceSection: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -58,9 +58,9 @@ const PreferenceSection: React.FC = () => {
}; };
return ( return (
<> <div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
<div className="w-full flex flex-col justify-start items-start gap-y-2"> <p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</p>
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</p> <div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center gap-x-1"> <div className="flex flex-row justify-start items-center gap-x-1">
<span className="dark:text-gray-400">{t("settings.preference.color-theme")}</span> <span className="dark:text-gray-400">{t("settings.preference.color-theme")}</span>
@ -91,7 +91,7 @@ const PreferenceSection: React.FC = () => {
</Select> </Select>
</div> </div>
</div> </div>
</> </div>
); );
}; };

View File

@ -1,13 +1,14 @@
import { Button, Checkbox, Textarea } from "@mui/joy"; import { Button, Input, Select, Textarea, Option, Switch, Link } from "@mui/joy";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { workspaceServiceClient } from "@/grpcweb"; import { workspaceServiceClient } from "@/grpcweb";
import useWorkspaceStore from "@/stores/v1/workspace"; import { useWorkspaceStore } from "@/stores";
import { WorkspaceSetting } from "@/types/proto/api/v2/workspace_service"; import { Visibility } from "@/types/proto/api/v1/common";
import { WorkspaceSetting } from "@/types/proto/api/v1/workspace_service";
const WorkspaceSection: React.FC = () => { const WorkspaceSection = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting); const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting);
@ -21,6 +22,20 @@ const WorkspaceSection: React.FC = () => {
}); });
}; };
const handleInstanceUrlChange = async (value: string) => {
setWorkspaceSetting({
...workspaceSetting,
instanceUrl: value,
});
};
const handleFaviconProvierChange = async (value: string) => {
setWorkspaceSetting({
...workspaceSetting,
faviconProvider: value,
});
};
const handleCustomStyleChange = async (value: string) => { const handleCustomStyleChange = async (value: string) => {
setWorkspaceSetting({ setWorkspaceSetting({
...workspaceSetting, ...workspaceSetting,
@ -28,14 +43,30 @@ const WorkspaceSection: React.FC = () => {
}); });
}; };
const handleDefaultVisibilityChange = async (value: Visibility) => {
setWorkspaceSetting({
...workspaceSetting,
defaultVisibility: value,
});
};
const handleSaveWorkspaceSetting = async () => { const handleSaveWorkspaceSetting = async () => {
const updateMask: string[] = []; const updateMask: string[] = [];
if (!isEqual(originalWorkspaceSetting.current.enableSignup, workspaceSetting.enableSignup)) { if (!isEqual(originalWorkspaceSetting.current.enableSignup, workspaceSetting.enableSignup)) {
updateMask.push("enable_signup"); updateMask.push("enable_signup");
} }
if (!isEqual(originalWorkspaceSetting.current.instanceUrl, workspaceSetting.instanceUrl)) {
updateMask.push("instance_url");
}
if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) { if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) {
updateMask.push("custom_style"); updateMask.push("custom_style");
} }
if (!isEqual(originalWorkspaceSetting.current.defaultVisibility, workspaceSetting.defaultVisibility)) {
updateMask.push("default_visibility");
}
if (!isEqual(originalWorkspaceSetting.current.faviconProvider, workspaceSetting.faviconProvider)) {
updateMask.push("favicon_provider");
}
if (updateMask.length === 0) { if (updateMask.length === 0) {
toast.error("No changes made"); toast.error("No changes made");
return; return;
@ -49,6 +80,7 @@ const WorkspaceSection: React.FC = () => {
}) })
).setting as WorkspaceSetting; ).setting as WorkspaceSetting;
setWorkspaceSetting(setting); setWorkspaceSetting(setting);
await workspaceStore.fetchWorkspaceSetting();
originalWorkspaceSetting.current = setting; originalWorkspaceSetting.current = setting;
toast.success("Workspace setting saved successfully"); toast.success("Workspace setting saved successfully");
} catch (error: any) { } catch (error: any) {
@ -57,30 +89,75 @@ const WorkspaceSection: React.FC = () => {
}; };
return ( return (
<div className="w-full flex flex-col justify-start items-start space-y-4"> <div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p> <p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
<p className="mt-2 dark:text-gray-400">{t("settings.workspace.custom-style")}</p> <div className="w-full flex flex-col justify-start items-start">
<Textarea <p className="font-medium dark:text-gray-400">Instance URL</p>
className="w-full mt-2" <Input
minRows={2} className="w-full mt-2"
maxRows={5} placeholder="Your instance URL. Using for website SEO. Leave it empty if you don't want cawler to index your website."
value={workspaceSetting.customStyle} value={workspaceSetting.instanceUrl}
onChange={(event) => handleCustomStyleChange(event.target.value)} onChange={(event) => handleInstanceUrlChange(event.target.value)}
/> />
</div> </div>
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<Checkbox <p className="font-medium dark:text-gray-400">Favicon Provider</p>
label={t("settings.workspace.enable-user-signup.self")} <p className="text-sm text-gray-500">
checked={workspaceSetting.enableSignup} e.g.{" "}
onChange={(event) => handleEnableSignUpChange(event.target.checked)} <Link className="!text-sm" href="https://github.com/yourselfhosted/favicons" target="_blank">
/> yourselfhosted/favicons
<p className="mt-2 text-gray-500">{t("settings.workspace.enable-user-signup.description")}</p> </Link>
</div> </p>
<div> <Input
<Button variant="outlined" color="neutral" disabled={!allowSave} onClick={handleSaveWorkspaceSetting}> className="w-full mt-2"
{t("common.save")} placeholder="The provider of favicon. Empty for default Google S2."
</Button> value={workspaceSetting.faviconProvider}
onChange={(event) => handleFaviconProvierChange(event.target.value)}
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<p className="mt-2 font-medium dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
<Textarea
className="w-full mt-2"
placeholder="* {font-family: ui-monospace Monaco Consolas;}"
minRows={2}
maxRows={5}
value={workspaceSetting.customStyle}
onChange={(event) => handleCustomStyleChange(event.target.value)}
/>
</div>
<div className="w-full flex flex-row justify-between items-center">
<div className="w-full flex flex-col justify-start items-start">
<p className="font-medium">{t("settings.workspace.enable-user-signup.self")}</p>
<p className="text-gray-500">{t("settings.workspace.enable-user-signup.description")}</p>
</div>
<div>
<Switch
size="lg"
checked={workspaceSetting.enableSignup}
onChange={(event) => handleEnableSignUpChange(event.target.checked)}
/>
</div>
</div>
<div className="w-full flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center gap-x-1">
<span className="font-medium dark:text-gray-400">{t("settings.workspace.default-visibility")}</span>
</div>
<Select
defaultValue={workspaceSetting.defaultVisibility || Visibility.PRIVATE}
onChange={(_, value) => handleDefaultVisibilityChange(value as Visibility)}
>
<Option value={Visibility.PRIVATE}>{t(`shortcut.visibility.private.self`)}</Option>
<Option value={Visibility.WORKSPACE}>{t(`shortcut.visibility.workspace.self`)}</Option>
<Option value={Visibility.PUBLIC}>{t(`shortcut.visibility.public.self`)}</Option>
</Select>
</div>
<div>
<Button variant="outlined" color="neutral" disabled={!allowSave} onClick={handleSaveWorkspaceSetting}>
{t("common.save")}
</Button>
</div>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,3 @@
.MuiDrawer-content {
@apply !w-auto;
}

View File

@ -1,8 +1,11 @@
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web"; import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
import { SubscriptionServiceDefinition } from "./types/proto/api/v2/subscription_service"; import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service";
import { UserServiceDefinition } from "./types/proto/api/v2/user_service"; import { CollectionServiceDefinition } from "./types/proto/api/v1/collection_service";
import { UserSettingServiceDefinition } from "./types/proto/api/v2/user_setting_service"; import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service";
import { WorkspaceServiceDefinition } from "./types/proto/api/v2/workspace_service"; import { SubscriptionServiceDefinition } from "./types/proto/api/v1/subscription_service";
import { UserServiceDefinition } from "./types/proto/api/v1/user_service";
import { UserSettingServiceDefinition } from "./types/proto/api/v1/user_setting_service";
import { WorkspaceServiceDefinition } from "./types/proto/api/v1/workspace_service";
const address = import.meta.env.MODE === "development" ? "http://localhost:8082" : window.location.origin; const address = import.meta.env.MODE === "development" ? "http://localhost:8082" : window.location.origin;
@ -15,10 +18,16 @@ const channel = createChannel(
const clientFactory = createClientFactory(); const clientFactory = createClientFactory();
export const workspaceServiceClient = clientFactory.create(WorkspaceServiceDefinition, channel);
export const subscriptionServiceClient = clientFactory.create(SubscriptionServiceDefinition, channel); export const subscriptionServiceClient = clientFactory.create(SubscriptionServiceDefinition, channel);
export const workspaceServiceClient = clientFactory.create(WorkspaceServiceDefinition, channel); export const authServiceClient = clientFactory.create(AuthServiceDefinition, channel);
export const userServiceClient = clientFactory.create(UserServiceDefinition, channel); export const userServiceClient = clientFactory.create(UserServiceDefinition, channel);
export const userSettingServiceClient = clientFactory.create(UserSettingServiceDefinition, channel); export const userSettingServiceClient = clientFactory.create(UserSettingServiceDefinition, channel);
export const shortcutServiceClient = clientFactory.create(ShortcutServiceDefinition, channel);
export const collectionServiceClient = clientFactory.create(CollectionServiceDefinition, channel);

View File

@ -1,73 +0,0 @@
import axios from "axios";
import { userServiceClient } from "@/grpcweb";
export function signin(email: string, password: string) {
return axios.post<User>("/api/v1/auth/signin", {
email,
password,
});
}
export function signup(email: string, nickname: string, password: string) {
return axios.post<User>("/api/v1/auth/signup", {
email,
nickname,
password,
});
}
export function signout() {
return axios.post("/api/v1/auth/logout");
}
export function getMyselfUser() {
return axios.get<User>("/api/v1/user/me");
}
export function getUserList() {
return axios.get<User[]>("/api/v1/user");
}
export function getUserById(id: number) {
return axios.get<User>(`/api/v1/user/${id}`);
}
export function createUser(userCreate: UserCreate) {
return axios.post<User>("/api/v1/user", userCreate);
}
export function patchUser(userPatch: UserPatch) {
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
}
export function deleteUser(userId: UserId) {
return userServiceClient.deleteUser({ id: userId });
}
export function getShortcutList(shortcutFind?: ShortcutFind) {
const queryList = [];
if (shortcutFind?.tag) {
queryList.push(`tag=${shortcutFind.tag}`);
}
return axios.get<Shortcut[]>(`/api/v1/shortcut?${queryList.join("&")}`);
}
export function getShortcutById(id: number) {
return axios.get<Shortcut>(`/api/v1/shortcut/${id}`);
}
export function createShortcut(shortcutCreate: ShortcutCreate) {
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
}
export function getShortcutAnalytics(shortcutId: ShortcutId) {
return axios.get<AnalysisData>(`/api/v1/shortcut/${shortcutId}/analytics`);
}
export function patchShortcut(shortcutPatch: ShortcutPatch) {
return axios.patch<Shortcut>(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch);
}
export function deleteShortcutById(shortcutId: ShortcutId) {
return axios.delete(`/api/v1/shortcut/${shortcutId}`);
}

View File

@ -1,24 +1,20 @@
import { isNull, isUndefined } from "lodash-es";
export const isNullorUndefined = (value: any) => {
return isNull(value) || isUndefined(value);
};
export const absolutifyLink = (rel: string): string => { export const absolutifyLink = (rel: string): string => {
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.setAttribute("href", rel); anchor.setAttribute("href", rel);
return anchor.href; return anchor.href;
}; };
export const releaseGuard = () => { export const isURL = (str: string): boolean => {
return import.meta.env.MODE === "development"; const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i;
return urlRegex.test(str);
}; };
export const getFaviconWithGoogleS2 = (url: string) => { export const generateRandomString = () => {
try { const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const urlObject = new URL(url); let randomString = "";
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`; for (let i = 0; i < 6; i++) {
} catch (error) { const randomIndex = Math.floor(Math.random() * characters.length);
return undefined; randomString += characters.charAt(randomIndex);
} }
return randomString;
}; };

View File

@ -0,0 +1,20 @@
/* eslint-disable no-unused-vars */
import useWindowSize from "react-use/lib/useWindowSize";
enum TailwindResponsiveWidth {
sm = 640,
md = 768,
lg = 1024,
}
const useResponsiveWidth = () => {
const { width } = useWindowSize();
return {
sm: width >= TailwindResponsiveWidth.sm,
md: width >= TailwindResponsiveWidth.md,
lg: width >= TailwindResponsiveWidth.lg,
};
};
export default useResponsiveWidth;

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