Compare commits
138 Commits
Author | SHA1 | Date | |
---|---|---|---|
cdfb015638 | |||
435fe04ab3 | |||
4e73882bf1 | |||
f2d9b29baa | |||
eaf9113c92 | |||
194571e132 | |||
f34b33c1a2 | |||
2552a7645e | |||
5dab623793 | |||
95f1570796 | |||
85848ee317 | |||
385499e642 | |||
59ee192bf8 | |||
2771602e5c | |||
38cd5fabee | |||
4bc2a0ff42 | |||
d46e83b735 | |||
a37fce2849 | |||
764d776524 | |||
7c9798b6b1 | |||
70b0645f2e | |||
88606e0a0c | |||
91708da5fc | |||
867d150a6d | |||
9259a85e69 | |||
546d87ca0b | |||
b73f7070e4 | |||
bec2c15ac9 | |||
d4c7de3916 | |||
47346182f0 | |||
aa1351f815 | |||
997b057a21 | |||
fb7fc2443f | |||
43cda4e2fb | |||
dbd3888fe1 | |||
6eb3ff412d | |||
4c66edc170 | |||
a7d48e8059 | |||
41cb597f03 | |||
a9071d629a | |||
9173c8f19a | |||
6350b19478 | |||
8b13c94b22 | |||
add523f8a5 | |||
5c3df55b72 | |||
5f69ab67df | |||
e7d2bd0be6 | |||
9ac6188707 | |||
3d109dc1b4 | |||
c45a48966d | |||
263812f98f | |||
b7999a4db2 | |||
38e5398cb9 | |||
a4e91541cf | |||
5e227da0c4 | |||
59e1281960 | |||
01e49e23b5 | |||
2f30162add | |||
3be52e7ab8 | |||
c85442d39f | |||
61b167ef66 | |||
c449669793 | |||
0c2283a831 | |||
50d9873ec1 | |||
0b5f54b5b2 | |||
ec581076ef | |||
c71575faed | |||
35785a1a28 | |||
832eb7cbf1 | |||
65504cf537 | |||
6fa1c30fb7 | |||
d18872aa5f | |||
5f94f3f893 | |||
b03c94f75d | |||
e9905cbc39 | |||
fdee03cc99 | |||
cb98be1891 | |||
80edd1b9a9 | |||
91ad30ae27 | |||
168ad39076 | |||
0a62579814 | |||
3da0e4720e | |||
dad0d91d01 | |||
92635fe395 | |||
fbc089569d | |||
2296eb96ef | |||
30d9dd04bb | |||
e89358cb0a | |||
bbe2bdffe3 | |||
af9655eeaf | |||
916423cc89 | |||
dddb643bed | |||
0f7a771e85 | |||
8d8b892d2a | |||
8a4e07120f | |||
8de658709c | |||
cb3e3bfaef | |||
4a25fbb2f6 | |||
83970d5d55 | |||
626b0df21c | |||
8f608dc522 | |||
8f982c5695 | |||
94baa04bb1 | |||
1505e9fa56 | |||
cab701f11b | |||
a3743d7ac6 | |||
7715905204 | |||
f770149066 | |||
f3f2218e91 | |||
b3e766926d | |||
6ed9ecffde | |||
c8d8c4e40c | |||
4f94927b5c | |||
f5f8616f2e | |||
033c007654 | |||
0fb5377226 | |||
f0afa13b8d | |||
53df3a9c1c | |||
8faaf8ced1 | |||
67c3bbf1ee | |||
68745ba9e0 | |||
015336b8c3 | |||
82ac6ab985 | |||
898ca70ad1 | |||
5b2a8394d7 | |||
16e17bffb3 | |||
015040cc1d | |||
c8869e67c7 | |||
a9ae7d2e96 | |||
db9034ccf9 | |||
4d1705dca5 | |||
3225e7c47b | |||
328397612c | |||
c846cde5b4 | |||
5c2cb99866 | |||
742c7da2eb | |||
88b247410f | |||
01417943fb |
3
.github/workflows/backend-tests.yml
vendored
@ -25,7 +25,8 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
args: -v
|
version: v1.54.1
|
||||||
|
args: --verbose --timeout=3m
|
||||||
skip-cache: true
|
skip-cache: true
|
||||||
|
|
||||||
go-tests:
|
go-tests:
|
||||||
|
6
.github/workflows/extension-test.yml
vendored
@ -15,9 +15,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: bufbuild/buf-setup-action@v1
|
|
||||||
- run: buf generate
|
|
||||||
working-directory: proto
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
@ -36,9 +33,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: bufbuild/buf-setup-action@v1
|
|
||||||
- run: buf generate
|
|
||||||
working-directory: proto
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
|
6
.github/workflows/frontend-test.yml
vendored
@ -15,9 +15,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: bufbuild/buf-setup-action@v1
|
|
||||||
- run: buf generate
|
|
||||||
working-directory: proto
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
@ -36,9 +33,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: bufbuild/buf-setup-action@v1
|
|
||||||
- run: buf generate
|
|
||||||
working-directory: proto
|
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
|
2
.gitignore
vendored
@ -10,3 +10,5 @@ build
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.env
|
||||||
|
@ -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"
|
||||||
|
22
Dockerfile
@ -1,24 +1,10 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@ -29,9 +15,8 @@ FROM golang:1.21-alpine AS backend
|
|||||||
WORKDIR /backend-build
|
WORKDIR /backend-build
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend /frontend-build/web/dist ./server/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
|
||||||
@ -40,6 +25,7 @@ WORKDIR /usr/local/slash
|
|||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
ENV TZ="UTC"
|
ENV TZ="UTC"
|
||||||
|
|
||||||
|
COPY --from=frontend /frontend-build/frontend/web/dist /usr/local/slash/dist
|
||||||
COPY --from=backend /backend-build/slash /usr/local/slash/
|
COPY --from=backend /backend-build/slash /usr/local/slash/
|
||||||
|
|
||||||
EXPOSE 5231
|
EXPOSE 5231
|
||||||
|
145
LICENSE
@ -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>.
|
|
||||||
|
34
README.md
@ -1,27 +1,35 @@
|
|||||||
# Slash
|
# Slash
|
||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
<img align="right" src="./docs/assets/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
**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.
|
**Slash** is an open source, self-hosted bookmarks and link 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.
|
||||||
|
|
||||||
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Discord</a>
|
🧩 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/)
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Join our Discord</a>
|
||||||
|
|
||||||
<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">
|

|
||||||
<a href="https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg"><b>🧩 Browser extension now available!</b></a></p>
|
|
||||||
|
|
||||||

|
## 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 +38,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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
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/).
|
||||||
|
1
api/v1/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
> The v1 API has been deprecated. Please use the v2 API instead.
|
@ -4,13 +4,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mssola/useragent"
|
"github.com/mssola/useragent"
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/internal/util"
|
||||||
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReferenceInfo struct {
|
type ReferenceInfo struct {
|
||||||
@ -37,13 +38,13 @@ type AnalysisData struct {
|
|||||||
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
||||||
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
shortcutID, err := util.ConvertStringToInt32(c.Param("shortcutId"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
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{
|
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
Type: store.ActivityShortcutView,
|
Type: store.ActivityShortcutView,
|
||||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
|
PayloadShortcutID: &shortcutID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
||||||
@ -78,6 +79,7 @@ func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
|||||||
browserMap[browserName]++
|
browserMap[browserName]++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut analytics")
|
||||||
return c.JSON(http.StatusOK, &AnalysisData{
|
return c.JSON(http.StatusOK, &AnalysisData{
|
||||||
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
||||||
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
||||||
@ -94,8 +96,8 @@ func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
|||||||
Count: value,
|
Count: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
|
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) int {
|
||||||
return i.Count > j.Count
|
return i.Count - j.Count
|
||||||
})
|
})
|
||||||
return referenceInfoSlice
|
return referenceInfoSlice
|
||||||
}
|
}
|
||||||
@ -108,8 +110,8 @@ func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
|||||||
Count: value,
|
Count: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
|
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) int {
|
||||||
return i.Count > j.Count
|
return i.Count - j.Count
|
||||||
})
|
})
|
||||||
return deviceInfoSlice
|
return deviceInfoSlice
|
||||||
}
|
}
|
||||||
@ -122,8 +124,8 @@ func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
|||||||
Count: value,
|
Count: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
|
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) int {
|
||||||
return i.Count > j.Count
|
return i.Count - j.Count
|
||||||
})
|
})
|
||||||
return browserInfoSlice
|
return browserInfoSlice
|
||||||
}
|
}
|
||||||
|
@ -11,10 +11,11 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
"github.com/yourselfhosted/slash/api/auth"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/server/service/license"
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SignInRequest struct {
|
type SignInRequest struct {
|
||||||
@ -63,6 +64,7 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
metric.Enqueue("user sign in")
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -129,6 +131,7 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
|||||||
|
|
||||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||||
|
metric.Enqueue("user sign up")
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -9,10 +9,10 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
"github.com/yourselfhosted/slash/api/auth"
|
||||||
"github.com/boojack/slash/internal/util"
|
"github.com/yourselfhosted/slash/internal/util"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -63,7 +63,7 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H
|
|||||||
accessToken := findAccessToken(c)
|
accessToken := findAccessToken(c)
|
||||||
if accessToken == "" {
|
if accessToken == "" {
|
||||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
// 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 {
|
if util.HasPrefixes(path, "/s/", "/api/v1/user/") && method == http.MethodGet {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,9 +10,10 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
"github.com/yourselfhosted/slash/internal/util"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Visibility is the type of a shortcut visibility.
|
// Visibility is the type of a shortcut visibility.
|
||||||
@ -101,6 +102,9 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
Tags: create.Tags,
|
Tags: create.Tags,
|
||||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||||
}
|
}
|
||||||
|
if create.Name == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "name is required")
|
||||||
|
}
|
||||||
if create.OpenGraphMetadata != nil {
|
if create.OpenGraphMetadata != nil {
|
||||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||||
Title: create.OpenGraphMetadata.Title,
|
Title: create.OpenGraphMetadata.Title,
|
||||||
@ -121,6 +125,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
metric.Enqueue("shortcut create")
|
||||||
return c.JSON(http.StatusOK, shortcutMessage)
|
return c.JSON(http.StatusOK, shortcutMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -177,7 +182,7 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
shortcutUpdate.Tag = &tag
|
shortcutUpdate.Tag = &tag
|
||||||
}
|
}
|
||||||
if patch.OpenGraphMetadata != nil {
|
if patch.OpenGraphMetadata != nil {
|
||||||
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
shortcutUpdate.OpenGraphMetadata = &storepb.OpenGraphMetadata{
|
||||||
Title: patch.OpenGraphMetadata.Title,
|
Title: patch.OpenGraphMetadata.Title,
|
||||||
Description: patch.OpenGraphMetadata.Description,
|
Description: patch.OpenGraphMetadata.Description,
|
||||||
Image: patch.OpenGraphMetadata.Image,
|
Image: patch.OpenGraphMetadata.Image,
|
||||||
@ -313,9 +318,9 @@ func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut)
|
|||||||
shortcut.Creator = convertUserFromStore(user)
|
shortcut.Creator = convertUserFromStore(user)
|
||||||
|
|
||||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
Type: store.ActivityShortcutView,
|
Type: store.ActivityShortcutView,
|
||||||
Level: store.ActivityInfo,
|
Level: store.ActivityInfo,
|
||||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
PayloadShortcutID: &shortcut.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "Failed to list activities")
|
return nil, errors.Wrap(err, "Failed to list activities")
|
||||||
|
@ -10,9 +10,10 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/util"
|
"github.com/yourselfhosted/slash/internal/util"
|
||||||
"github.com/boojack/slash/server/service/license"
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -137,6 +138,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userMessage := convertUserFromStore(user)
|
userMessage := convertUserFromStore(user)
|
||||||
|
metric.Enqueue("user create")
|
||||||
return c.JSON(http.StatusOK, userMessage)
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -186,7 +188,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
userMessage := convertUserFromStore(user)
|
||||||
|
userID, ok := c.Get(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
userMessage.Email = ""
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
})
|
})
|
||||||
|
|
||||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||||
|
12
api/v1/v1.go
@ -3,9 +3,9 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/yourselfhosted/slash/server/profile"
|
||||||
"github.com/boojack/slash/server/service/license"
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIV1Service struct {
|
type APIV1Service struct {
|
||||||
@ -32,10 +32,4 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|||||||
s.registerUserRoutes(apiV1Group)
|
s.registerUserRoutes(apiV1Group)
|
||||||
s.registerShortcutRoutes(apiV1Group)
|
s.registerShortcutRoutes(apiV1Group)
|
||||||
s.registerAnalyticsRoutes(apiV1Group)
|
s.registerAnalyticsRoutes(apiV1Group)
|
||||||
|
|
||||||
redirectorGroup := apiGroup.Group("/s")
|
|
||||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return JWTMiddleware(s, next, secret)
|
|
||||||
})
|
|
||||||
s.registerRedirectorRoutes(redirectorGroup)
|
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,9 @@ import (
|
|||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/yourselfhosted/slash/server/profile"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspaceProfile struct {
|
type WorkspaceProfile struct {
|
||||||
|
@ -12,10 +12,10 @@ import (
|
|||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
"github.com/yourselfhosted/slash/api/auth"
|
||||||
"github.com/boojack/slash/internal/util"
|
"github.com/yourselfhosted/slash/internal/util"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextKey is the key type of context value.
|
// ContextKey is the key type of context value.
|
||||||
|
@ -3,8 +3,15 @@ package v2
|
|||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
var allowedMethodsWhenUnauthorized = map[string]bool{
|
var allowedMethodsWhenUnauthorized = map[string]bool{
|
||||||
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||||
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
||||||
|
"/slash.api.v2.AuthService/SignIn": true,
|
||||||
|
"/slash.api.v2.AuthService/SignUp": true,
|
||||||
|
"/slash.api.v2.AuthService/SignOut": true,
|
||||||
|
"/memos.api.v2.AuthService/GetAuthStatus": true,
|
||||||
|
"/slash.api.v2.ShortcutService/GetShortcutByName": true,
|
||||||
|
"/slash.api.v2.ShortcutService/GetShortcut": true,
|
||||||
|
"/slash.api.v2.CollectionService/GetCollectionByName": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
||||||
@ -19,6 +26,7 @@ var allowedMethodsOnlyForAdmin = map[string]bool{
|
|||||||
"/slash.api.v2.UserService/CreateUser": true,
|
"/slash.api.v2.UserService/CreateUser": true,
|
||||||
"/slash.api.v2.UserService/DeleteUser": true,
|
"/slash.api.v2.UserService/DeleteUser": true,
|
||||||
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
||||||
|
"/slash.api.v2.SubscriptionService/UpdateSubscription": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
||||||
|
149
api/v2/auth_service.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
"github.com/yourselfhosted/slash/api/auth"
|
||||||
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetAuthStatus(ctx context.Context, _ *apiv2pb.GetAuthStatusRequest) (*apiv2pb.GetAuthStatusResponse, error) {
|
||||||
|
user, err := getCurrentUser(ctx, s.Store)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "failed to get current user: %v", err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, status.Errorf(codes.Unauthenticated, "user not found")
|
||||||
|
}
|
||||||
|
return &apiv2pb.GetAuthStatusResponse{
|
||||||
|
User: convertUserFromStore(user),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) SignIn(ctx context.Context, request *apiv2pb.SignInRequest) (*apiv2pb.SignInResponse, error) {
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
Email: &request.Email,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to find user by email %s", request.Email))
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, fmt.Sprintf("user not found with email %s", request.Email))
|
||||||
|
} else if user.RowStatus == store.Archived {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, fmt.Sprintf("user has been archived with email %s", request.Email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(request.Password)); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmatched email and password")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to generate tokens, err: %s", err))
|
||||||
|
}
|
||||||
|
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, "user login"); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to upsert access token to store, err: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
||||||
|
"Set-Cookie": fmt.Sprintf("%s=%s; Path=/; Expires=%s; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName, accessToken, time.Now().Add(auth.AccessTokenDuration).Format(time.RFC1123)),
|
||||||
|
})); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to set grpc header, error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("user sign in")
|
||||||
|
return &apiv2pb.SignInResponse{
|
||||||
|
User: convertUserFromStore(user),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) SignUp(ctx context.Context, request *apiv2pb.SignUpRequest) (*apiv2pb.SignUpResponse, error) {
|
||||||
|
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||||
|
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to get workspace setting, err: %s", err))
|
||||||
|
}
|
||||||
|
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "sign up is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to list users, err: %s", err))
|
||||||
|
}
|
||||||
|
if len(userList) >= 5 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "maximum number of users reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to generate password hash, err: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
create := &store.User{
|
||||||
|
Email: request.Email,
|
||||||
|
Nickname: request.Nickname,
|
||||||
|
PasswordHash: string(passwordHash),
|
||||||
|
}
|
||||||
|
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to list users, err: %s", 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 nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to create user, err: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(s.Secret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to generate tokens, err: %s", err))
|
||||||
|
}
|
||||||
|
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken, "user login"); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, fmt.Sprintf("failed to upsert access token to store, err: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
||||||
|
"Set-Cookie": fmt.Sprintf("%s=%s; Path=/; Expires=%s; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName, accessToken, time.Now().Add(auth.AccessTokenDuration).Format(time.RFC1123)),
|
||||||
|
})); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to set grpc header, error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("user sign up")
|
||||||
|
return &apiv2pb.SignUpResponse{
|
||||||
|
User: convertUserFromStore(user),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*APIV2Service) SignOut(ctx context.Context, _ *apiv2pb.SignOutRequest) (*apiv2pb.SignOutResponse, error) {
|
||||||
|
// Set the cookie header to expire access token.
|
||||||
|
if err := grpc.SetHeader(ctx, metadata.New(map[string]string{
|
||||||
|
"Set-Cookie": fmt.Sprintf("%s=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict", auth.AccessTokenCookieName),
|
||||||
|
})); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to set grpc header, error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &apiv2pb.SignOutResponse{}, nil
|
||||||
|
}
|
236
api/v2/collection_service.go
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) ListCollections(ctx context.Context, _ *apiv2pb.ListCollectionsRequest) (*apiv2pb.ListCollectionsResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
collections, err := s.Store.ListCollections(ctx, &store.FindCollection{
|
||||||
|
CreatorID: &userID,
|
||||||
|
VisibilityList: []store.Visibility{
|
||||||
|
store.VisibilityPrivate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedCollections, err := s.Store.ListCollections(ctx, &store.FindCollection{
|
||||||
|
VisibilityList: []store.Visibility{
|
||||||
|
store.VisibilityWorkspace,
|
||||||
|
store.VisibilityPublic,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
||||||
|
}
|
||||||
|
collections = append(collections, sharedCollections...)
|
||||||
|
|
||||||
|
convertedCollections := []*apiv2pb.Collection{}
|
||||||
|
for _, collection := range collections {
|
||||||
|
convertedCollections = append(convertedCollections, convertCollectionFromStore(collection))
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.ListCollectionsResponse{
|
||||||
|
Collections: convertedCollections,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetCollection(ctx context.Context, request *apiv2pb.GetCollectionRequest) (*apiv2pb.GetCollectionResponse, error) {
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetCollectionResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetCollectionByName(ctx context.Context, request *apiv2pb.GetCollectionByNameRequest) (*apiv2pb.GetCollectionByNameResponse, error) {
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
Name: &request.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if ok {
|
||||||
|
if collection.Visibility == storepb.Visibility_PRIVATE && collection.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if collection.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetCollectionByNameResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
metric.Enqueue("collection view")
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) CreateCollection(ctx context.Context, request *apiv2pb.CreateCollectionRequest) (*apiv2pb.CreateCollectionResponse, error) {
|
||||||
|
if request.Collection.Name == "" || request.Collection.Title == "" {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "name and title are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||||
|
collections, err := s.Store.ListCollections(ctx, &store.FindCollection{
|
||||||
|
VisibilityList: []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err)
|
||||||
|
}
|
||||||
|
if len(collections) >= 5 {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Maximum number of collections reached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
collection := &storepb.Collection{
|
||||||
|
CreatorId: userID,
|
||||||
|
Name: request.Collection.Name,
|
||||||
|
Title: request.Collection.Title,
|
||||||
|
Description: request.Collection.Description,
|
||||||
|
ShortcutIds: request.Collection.ShortcutIds,
|
||||||
|
Visibility: storepb.Visibility(request.Collection.Visibility),
|
||||||
|
}
|
||||||
|
collection, err := s.Store.CreateCollection(ctx, collection)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create collection, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.CreateCollectionResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
metric.Enqueue("collection create")
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) UpdateCollection(ctx context.Context, request *apiv2pb.UpdateCollectionRequest) (*apiv2pb.UpdateCollectionResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
ID: &request.Collection.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &store.UpdateCollection{
|
||||||
|
ID: collection.Id,
|
||||||
|
}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
switch path {
|
||||||
|
case "name":
|
||||||
|
update.Name = &request.Collection.Name
|
||||||
|
case "title":
|
||||||
|
update.Title = &request.Collection.Title
|
||||||
|
case "description":
|
||||||
|
update.Description = &request.Collection.Description
|
||||||
|
case "shortcut_ids":
|
||||||
|
update.ShortcutIDs = request.Collection.ShortcutIds
|
||||||
|
case "visibility":
|
||||||
|
visibility := store.Visibility(request.Collection.Visibility.String())
|
||||||
|
update.Visibility = &visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collection, err = s.Store.UpdateCollection(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update collection, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.UpdateCollectionResponse{
|
||||||
|
Collection: convertCollectionFromStore(collection),
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) DeleteCollection(ctx context.Context, request *apiv2pb.DeleteCollectionRequest) (*apiv2pb.DeleteCollectionResponse, 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)
|
||||||
|
}
|
||||||
|
collection, err := s.Store.GetCollection(ctx, &store.FindCollection{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get collection by name: %v", err)
|
||||||
|
}
|
||||||
|
if collection == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "collection not found")
|
||||||
|
}
|
||||||
|
if collection.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.DeleteCollection(ctx, &store.DeleteCollection{
|
||||||
|
ID: collection.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to delete collection, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.DeleteCollectionResponse{}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertCollectionFromStore(collection *storepb.Collection) *apiv2pb.Collection {
|
||||||
|
return &apiv2pb.Collection{
|
||||||
|
Id: collection.Id,
|
||||||
|
CreatorId: collection.CreatorId,
|
||||||
|
CreatedTime: timestamppb.New(time.Unix(collection.CreatedTs, 0)),
|
||||||
|
UpdatedTime: timestamppb.New(time.Unix(collection.UpdatedTs, 0)),
|
||||||
|
Name: collection.Name,
|
||||||
|
Title: collection.Title,
|
||||||
|
Description: collection.Description,
|
||||||
|
ShortcutIds: collection.ShortcutIds,
|
||||||
|
Visibility: apiv2pb.Visibility(collection.Visibility),
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,10 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
"context"
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
||||||
@ -15,3 +17,17 @@ func convertRowStatusFromStore(rowStatus store.RowStatus) apiv2pb.RowStatus {
|
|||||||
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
return apiv2pb.RowStatus_ROW_STATUS_UNSPECIFIED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCurrentUser(ctx context.Context, s *store.Store) (*store.User, error) {
|
||||||
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
user, err := s.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
187
api/v2/memo_service.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV2Service) ListMemos(ctx context.Context, _ *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
|
||||||
|
find := &store.FindMemo{}
|
||||||
|
memos, err := s.Store.ListMemos(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to fetch memo list, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedMemos := []*apiv2pb.Memo{}
|
||||||
|
for _, memo := range memos {
|
||||||
|
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||||
|
}
|
||||||
|
composedMemos = append(composedMemos, composedMemo)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &apiv2pb.ListMemosResponse{
|
||||||
|
Memos: composedMemos,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetMemoResponse{
|
||||||
|
Memo: composedMemo,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMemoRequest) (*apiv2pb.CreateMemoResponse, error) {
|
||||||
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
|
memo := &storepb.Memo{
|
||||||
|
CreatorId: userID,
|
||||||
|
Name: request.Memo.Name,
|
||||||
|
Title: request.Memo.Title,
|
||||||
|
Content: request.Memo.Content,
|
||||||
|
Tags: request.Memo.Tags,
|
||||||
|
Visibility: storepb.Visibility(request.Memo.Visibility),
|
||||||
|
}
|
||||||
|
memo, err := s.Store.CreateMemo(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to create memo, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.CreateMemoResponse{
|
||||||
|
Memo: composedMemo,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMemoRequest) (*apiv2pb.UpdateMemoResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &request.Memo.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||||
|
}
|
||||||
|
if memo.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &store.UpdateMemo{
|
||||||
|
ID: memo.Id,
|
||||||
|
}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
switch path {
|
||||||
|
case "name":
|
||||||
|
update.Name = &request.Memo.Name
|
||||||
|
case "title":
|
||||||
|
update.Title = &request.Memo.Title
|
||||||
|
case "content":
|
||||||
|
update.Content = &request.Memo.Content
|
||||||
|
case "tags":
|
||||||
|
tag := strings.Join(request.Memo.Tags, " ")
|
||||||
|
update.Tag = &tag
|
||||||
|
case "visibility":
|
||||||
|
visibility := store.Visibility(request.Memo.Visibility.String())
|
||||||
|
update.Visibility = &visibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memo, err = s.Store.UpdateMemo(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update memo, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.UpdateMemoResponse{
|
||||||
|
Memo: composedMemo,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) DeleteMemo(ctx context.Context, request *apiv2pb.DeleteMemoRequest) (*apiv2pb.DeleteMemoResponse, 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)
|
||||||
|
}
|
||||||
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
||||||
|
}
|
||||||
|
if memo == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||||
|
}
|
||||||
|
if memo.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
||||||
|
ID: memo.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to delete memo, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.DeleteMemoResponse{}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*APIV2Service) convertMemoFromStorepb(_ context.Context, memo *storepb.Memo) (*apiv2pb.Memo, error) {
|
||||||
|
return &apiv2pb.Memo{
|
||||||
|
Id: memo.Id,
|
||||||
|
CreatedTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
|
||||||
|
UpdatedTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
|
||||||
|
CreatorId: memo.CreatorId,
|
||||||
|
Name: memo.Name,
|
||||||
|
Title: memo.Title,
|
||||||
|
Content: memo.Content,
|
||||||
|
Tags: memo.Tags,
|
||||||
|
Visibility: apiv2pb.Visibility(memo.Visibility),
|
||||||
|
}, nil
|
||||||
|
}
|
@ -2,33 +2,27 @@ package v2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mssola/useragent"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/peer"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ShortcutService struct {
|
func (s *APIV2Service) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
|
||||||
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)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
find := &store.FindShortcut{}
|
find := &store.FindShortcut{}
|
||||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||||
@ -47,7 +41,11 @@ func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShor
|
|||||||
shortcutList = append(shortcutList, visibleShortcutList...)
|
shortcutList = append(shortcutList, visibleShortcutList...)
|
||||||
shortcuts := []*apiv2pb.Shortcut{}
|
shortcuts := []*apiv2pb.Shortcut{}
|
||||||
for _, shortcut := range shortcutList {
|
for _, shortcut := range shortcutList {
|
||||||
shortcuts = append(shortcuts, convertShortcutFromStorepb(shortcut))
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
shortcuts = append(shortcuts, composedShortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &apiv2pb.ListShortcutsResponse{
|
response := &apiv2pb.ListShortcutsResponse{
|
||||||
@ -56,7 +54,39 @@ func (s *ShortcutService) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShor
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
|
func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShortcutRequest) (*apiv2pb.GetShortcutResponse, error) {
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by id: %v", err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
|
if ok {
|
||||||
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) GetShortcutByName(ctx context.Context, request *apiv2pb.GetShortcutByNameRequest) (*apiv2pb.GetShortcutByNameResponse, error) {
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
Name: &request.Name,
|
Name: &request.Name,
|
||||||
})
|
})
|
||||||
@ -67,18 +97,37 @@ func (s *ShortcutService) GetShortcut(ctx context.Context, request *apiv2pb.GetS
|
|||||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
if ok {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
if shortcut.Visibility == storepb.Visibility_PRIVATE && shortcut.CreatorId != userID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if shortcut.Visibility != storepb.Visibility_PUBLIC {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
shortcutMessage := convertShortcutFromStorepb(shortcut)
|
|
||||||
response := &apiv2pb.GetShortcutResponse{
|
// Create shortcut view activity.
|
||||||
Shortcut: shortcutMessage,
|
if err := s.createShortcutViewActivity(ctx, shortcut); err != nil {
|
||||||
|
fmt.Printf("failed to create activity, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.GetShortcutByNameResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
|
func (s *APIV2Service) CreateShortcut(ctx context.Context, request *apiv2pb.CreateShortcutRequest) (*apiv2pb.CreateShortcutResponse, error) {
|
||||||
|
if request.Shortcut.Name == "" || request.Shortcut.Link == "" {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "name and link are required")
|
||||||
|
}
|
||||||
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
shortcut := &storepb.Shortcut{
|
shortcut := &storepb.Shortcut{
|
||||||
CreatorId: userID,
|
CreatorId: userID,
|
||||||
@ -105,13 +154,21 @@ func (s *ShortcutService) CreateShortcut(ctx context.Context, request *apiv2pb.C
|
|||||||
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to create activity, err: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
response := &apiv2pb.CreateShortcutResponse{
|
response := &apiv2pb.CreateShortcutResponse{
|
||||||
Shortcut: convertShortcutFromStorepb(shortcut),
|
Shortcut: composedShortcut,
|
||||||
}
|
}
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShortcutService) DeleteShortcut(ctx context.Context, request *apiv2pb.DeleteShortcutRequest) (*apiv2pb.DeleteShortcutResponse, error) {
|
func (s *APIV2Service) UpdateShortcut(ctx context.Context, request *apiv2pb.UpdateShortcutRequest) (*apiv2pb.UpdateShortcutResponse, error) {
|
||||||
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||||
|
}
|
||||||
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &userID,
|
ID: &userID,
|
||||||
@ -120,10 +177,75 @@ func (s *ShortcutService) DeleteShortcut(ctx context.Context, request *apiv2pb.D
|
|||||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||||
}
|
}
|
||||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
Name: &request.Name,
|
ID: &request.Shortcut.Id,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by id: %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")
|
||||||
|
}
|
||||||
|
|
||||||
|
update := &store.UpdateShortcut{
|
||||||
|
ID: shortcut.Id,
|
||||||
|
}
|
||||||
|
for _, path := range request.UpdateMask.Paths {
|
||||||
|
switch path {
|
||||||
|
case "name":
|
||||||
|
update.Name = &request.Shortcut.Name
|
||||||
|
case "link":
|
||||||
|
update.Link = &request.Shortcut.Link
|
||||||
|
case "title":
|
||||||
|
update.Title = &request.Shortcut.Title
|
||||||
|
case "description":
|
||||||
|
update.Description = &request.Shortcut.Description
|
||||||
|
case "tags":
|
||||||
|
tag := strings.Join(request.Shortcut.Tags, " ")
|
||||||
|
update.Tag = &tag
|
||||||
|
case "visibility":
|
||||||
|
visibility := store.Visibility(request.Shortcut.Visibility.String())
|
||||||
|
update.Visibility = &visibility
|
||||||
|
case "og_metadata":
|
||||||
|
if request.Shortcut.OgMetadata != nil {
|
||||||
|
update.OpenGraphMetadata = &storepb.OpenGraphMetadata{
|
||||||
|
Title: request.Shortcut.OgMetadata.Title,
|
||||||
|
Description: request.Shortcut.OgMetadata.Description,
|
||||||
|
Image: request.Shortcut.OgMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shortcut, err = s.Store.UpdateShortcut(ctx, update)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composedShortcut, err := s.convertShortcutFromStorepb(ctx, shortcut)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to convert shortcut, err: %v", err)
|
||||||
|
}
|
||||||
|
response := &apiv2pb.UpdateShortcutResponse{
|
||||||
|
Shortcut: composedShortcut,
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) 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{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by id: %v", err)
|
||||||
}
|
}
|
||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
@ -142,7 +264,107 @@ func (s *ShortcutService) DeleteShortcut(ctx context.Context, request *apiv2pb.D
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
func (s *APIV2Service) GetShortcutAnalytics(ctx context.Context, request *apiv2pb.GetShortcutAnalyticsRequest) (*apiv2pb.GetShortcutAnalyticsResponse, error) {
|
||||||
|
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||||
|
ID: &request.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get shortcut by id: %v", err)
|
||||||
|
}
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, status.Errorf(codes.NotFound, "shortcut not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
PayloadShortcutID: &shortcut.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get activities, err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
referenceMap := make(map[string]int32)
|
||||||
|
deviceMap := make(map[string]int32)
|
||||||
|
browserMap := make(map[string]int32)
|
||||||
|
for _, activity := range activities {
|
||||||
|
payload := &storepb.ActivityShorcutViewPayload{}
|
||||||
|
if err := protojson.Unmarshal([]byte(activity.Payload), payload); err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, fmt.Sprintf("failed to unmarshal payload, err: %v", 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]++
|
||||||
|
}
|
||||||
|
|
||||||
|
metric.Enqueue("shortcut analytics")
|
||||||
|
response := &apiv2pb.GetShortcutAnalyticsResponse{
|
||||||
|
References: mapToAnalyticsSlice(referenceMap),
|
||||||
|
Devices: mapToAnalyticsSlice(deviceMap),
|
||||||
|
Browsers: mapToAnalyticsSlice(browserMap),
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToAnalyticsSlice(m map[string]int32) []*apiv2pb.GetShortcutAnalyticsResponse_AnalyticsItem {
|
||||||
|
analyticsSlice := make([]*apiv2pb.GetShortcutAnalyticsResponse_AnalyticsItem, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
analyticsSlice = append(analyticsSlice, &apiv2pb.GetShortcutAnalyticsResponse_AnalyticsItem{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(analyticsSlice, func(i, j *apiv2pb.GetShortcutAnalyticsResponse_AnalyticsItem) int {
|
||||||
|
return int(i.Count - j.Count)
|
||||||
|
})
|
||||||
|
return analyticsSlice
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) createShortcutViewActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||||
|
p, _ := peer.FromContext(ctx)
|
||||||
|
headers, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("Failed to get metadata from context")
|
||||||
|
}
|
||||||
|
payload := &storepb.ActivityShorcutViewPayload{
|
||||||
|
ShortcutId: shortcut.Id,
|
||||||
|
Ip: p.Addr.String(),
|
||||||
|
Referer: headers.Get("referer")[0],
|
||||||
|
UserAgent: headers.Get("user-agent")[0],
|
||||||
|
}
|
||||||
|
payloadStr, err := protojson.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(ctx, activity)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to create activity")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV2Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||||
payload := &storepb.ActivityShorcutCreatePayload{
|
payload := &storepb.ActivityShorcutCreatePayload{
|
||||||
ShortcutId: shortcut.Id,
|
ShortcutId: shortcut.Id,
|
||||||
}
|
}
|
||||||
@ -163,12 +385,12 @@ func (s *ShortcutService) createShortcutCreateActivity(ctx context.Context, shor
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
|
func (s *APIV2Service) convertShortcutFromStorepb(ctx context.Context, shortcut *storepb.Shortcut) (*apiv2pb.Shortcut, error) {
|
||||||
return &apiv2pb.Shortcut{
|
composedShortcut := &apiv2pb.Shortcut{
|
||||||
Id: shortcut.Id,
|
Id: shortcut.Id,
|
||||||
CreatorId: shortcut.CreatorId,
|
CreatorId: shortcut.CreatorId,
|
||||||
CreatedTs: shortcut.CreatedTs,
|
CreatedTime: timestamppb.New(time.Unix(shortcut.CreatedTs, 0)),
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
UpdatedTime: timestamppb.New(time.Unix(shortcut.UpdatedTs, 0)),
|
||||||
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
|
RowStatus: apiv2pb.RowStatus(shortcut.RowStatus),
|
||||||
Name: shortcut.Name,
|
Name: shortcut.Name,
|
||||||
Link: shortcut.Link,
|
Link: shortcut.Link,
|
||||||
@ -182,4 +404,16 @@ func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *apiv2pb.Shortcut {
|
|||||||
Image: shortcut.OgMetadata.Image,
|
Image: shortcut.OgMetadata.Image,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
PayloadShortcutID: &composedShortcut.Id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Failed to list activities")
|
||||||
|
}
|
||||||
|
composedShortcut.ViewCount = int32(len(activityList))
|
||||||
|
|
||||||
|
return composedShortcut, nil
|
||||||
}
|
}
|
||||||
|
@ -6,30 +6,10 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
apiv2pb "github.com/yourselfhosted/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 {
|
func (s *APIV2Service) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {
|
||||||
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)
|
subscription, err := s.LicenseService.LoadSubscription(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||||
@ -39,7 +19,7 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, _ *apiv2pb.Ge
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubscriptionService) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
|
func (s *APIV2Service) UpdateSubscription(ctx context.Context, request *apiv2pb.UpdateSubscriptionRequest) (*apiv2pb.UpdateSubscriptionResponse, error) {
|
||||||
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
subscription, err := s.LicenseService.UpdateSubscription(ctx, request.LicenseKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to load subscription: %v", err)
|
||||||
|
@ -12,31 +12,19 @@ import (
|
|||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
"github.com/boojack/slash/api/auth"
|
"github.com/yourselfhosted/slash/api/auth"
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/server/service/license"
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct {
|
const (
|
||||||
apiv2pb.UnimplementedUserServiceServer
|
// BotID is the id of bot.
|
||||||
|
BotID = 0
|
||||||
|
)
|
||||||
|
|
||||||
Secret string
|
func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
|
||||||
Store *store.Store
|
|
||||||
LicenseService *license.LicenseService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserService creates a new UserService.
|
|
||||||
func NewUserService(secret string, store *store.Store, licenseService *license.LicenseService) *UserService {
|
|
||||||
return &UserService{
|
|
||||||
Secret: secret,
|
|
||||||
Store: store,
|
|
||||||
LicenseService: licenseService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {
|
|
||||||
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||||
@ -52,7 +40,7 @@ func (s *UserService) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
func (s *APIV2Service) GetUser(ctx context.Context, request *apiv2pb.GetUserRequest) (*apiv2pb.GetUserResponse, error) {
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
ID: &request.Id,
|
ID: &request.Id,
|
||||||
})
|
})
|
||||||
@ -70,7 +58,7 @@ func (s *UserService) GetUser(ctx context.Context, request *apiv2pb.GetUserReque
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
|
func (s *APIV2Service) CreateUser(ctx context.Context, request *apiv2pb.CreateUserRequest) (*apiv2pb.CreateUserResponse, error) {
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to hash password: %v", err)
|
||||||
@ -101,19 +89,19 @@ func (s *UserService) CreateUser(ctx context.Context, request *apiv2pb.CreateUse
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
func (s *APIV2Service) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUserRequest) (*apiv2pb.UpdateUserResponse, error) {
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.User.Id {
|
if userID != request.User.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
}
|
}
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "UpdateMask is empty")
|
return nil, status.Errorf(codes.InvalidArgument, "UpdateMask is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
userUpdate := &store.UpdateUser{
|
userUpdate := &store.UpdateUser{
|
||||||
ID: request.User.Id,
|
ID: request.User.Id,
|
||||||
}
|
}
|
||||||
for _, path := range request.UpdateMask {
|
for _, path := range request.UpdateMask.Paths {
|
||||||
if path == "email" {
|
if path == "email" {
|
||||||
userUpdate.Email = &request.User.Email
|
userUpdate.Email = &request.User.Email
|
||||||
} else if path == "nickname" {
|
} else if path == "nickname" {
|
||||||
@ -129,7 +117,7 @@ func (s *UserService) UpdateUser(ctx context.Context, request *apiv2pb.UpdateUse
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUserRequest) (*apiv2pb.DeleteUserResponse, error) {
|
func (s *APIV2Service) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUserRequest) (*apiv2pb.DeleteUserResponse, error) {
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID == request.Id {
|
if userID == request.Id {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "cannot delete yourself")
|
return nil, status.Errorf(codes.InvalidArgument, "cannot delete yourself")
|
||||||
@ -145,7 +133,7 @@ func (s *UserService) DeleteUser(ctx context.Context, request *apiv2pb.DeleteUse
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
func (s *APIV2Service) ListUserAccessTokens(ctx context.Context, request *apiv2pb.ListUserAccessTokensRequest) (*apiv2pb.ListUserAccessTokensResponse, error) {
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.Id {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
@ -187,8 +175,8 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by issued time in descending order.
|
// Sort by issued time in descending order.
|
||||||
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) bool {
|
slices.SortFunc(accessTokens, func(i, j *apiv2pb.UserAccessToken) int {
|
||||||
return i.IssuedAt.Seconds > j.IssuedAt.Seconds
|
return int(i.IssuedAt.Seconds - j.IssuedAt.Seconds)
|
||||||
})
|
})
|
||||||
response := &apiv2pb.ListUserAccessTokensResponse{
|
response := &apiv2pb.ListUserAccessTokensResponse{
|
||||||
AccessTokens: accessTokens,
|
AccessTokens: accessTokens,
|
||||||
@ -196,7 +184,7 @@ func (s *UserService) ListUserAccessTokens(ctx context.Context, request *apiv2pb
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
func (s *APIV2Service) CreateUserAccessToken(ctx context.Context, request *apiv2pb.CreateUserAccessTokenRequest) (*apiv2pb.CreateUserAccessTokenResponse, error) {
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.Id {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
@ -256,7 +244,7 @@ func (s *UserService) CreateUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
func (s *APIV2Service) DeleteUserAccessToken(ctx context.Context, request *apiv2pb.DeleteUserAccessTokenRequest) (*apiv2pb.DeleteUserAccessTokenResponse, error) {
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
if userID != request.Id {
|
if userID != request.Id {
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||||
@ -288,7 +276,7 @@ func (s *UserService) DeleteUserAccessToken(ctx context.Context, request *apiv2p
|
|||||||
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
return &apiv2pb.DeleteUserAccessTokenResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
func (s *APIV2Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken, description string) error {
|
||||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "failed to get user access tokens")
|
return errors.Wrap(err, "failed to get user access tokens")
|
||||||
@ -314,13 +302,13 @@ func (s *UserService) UpsertAccessTokenToStore(ctx context.Context, user *store.
|
|||||||
|
|
||||||
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
func convertUserFromStore(user *store.User) *apiv2pb.User {
|
||||||
return &apiv2pb.User{
|
return &apiv2pb.User{
|
||||||
Id: int32(user.ID),
|
Id: int32(user.ID),
|
||||||
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
RowStatus: convertRowStatusFromStore(user.RowStatus),
|
||||||
CreatedTs: user.CreatedTs,
|
CreatedTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
|
||||||
UpdatedTs: user.UpdatedTs,
|
UpdatedTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
|
||||||
Role: convertUserRoleFromStore(user.Role),
|
Role: convertUserRoleFromStore(user.Role),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,25 +7,12 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserSettingService struct {
|
func (s *APIV2Service) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
||||||
apiv2pb.UnimplementedUserSettingServiceServer
|
|
||||||
|
|
||||||
Store *store.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserSettingService creates a new UserSettingService.
|
|
||||||
func NewUserSettingService(store *store.Store) *UserSettingService {
|
|
||||||
return &UserSettingService{
|
|
||||||
Store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserSettingService) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {
|
|
||||||
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
userSetting, err := getUserSetting(ctx, s.Store, request.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
|
||||||
@ -35,13 +22,13 @@ func (s *UserSettingService) GetUserSetting(ctx context.Context, request *apiv2p
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserSettingService) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
func (s *APIV2Service) UpdateUserSetting(ctx context.Context, request *apiv2pb.UpdateUserSettingRequest) (*apiv2pb.UpdateUserSettingResponse, error) {
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := ctx.Value(userIDContextKey).(int32)
|
userID := ctx.Value(userIDContextKey).(int32)
|
||||||
for _, path := range request.UpdateMask {
|
for _, path := range request.UpdateMask.Paths {
|
||||||
if path == "locale" {
|
if path == "locale" {
|
||||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||||
UserId: userID,
|
UserId: userID,
|
||||||
|
51
api/v2/v2.go
@ -4,20 +4,29 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
grpcRuntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/grpc/reflection"
|
"google.golang.org/grpc/reflection"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/yourselfhosted/slash/server/profile"
|
||||||
"github.com/boojack/slash/server/service/license"
|
"github.com/yourselfhosted/slash/server/service/license"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIV2Service struct {
|
type APIV2Service struct {
|
||||||
|
apiv2pb.UnimplementedWorkspaceServiceServer
|
||||||
|
apiv2pb.UnimplementedSubscriptionServiceServer
|
||||||
|
apiv2pb.UnimplementedAuthServiceServer
|
||||||
|
apiv2pb.UnimplementedUserServiceServer
|
||||||
|
apiv2pb.UnimplementedUserSettingServiceServer
|
||||||
|
apiv2pb.UnimplementedShortcutServiceServer
|
||||||
|
apiv2pb.UnimplementedCollectionServiceServer
|
||||||
|
apiv2pb.UnimplementedMemoServiceServer
|
||||||
|
|
||||||
Secret string
|
Secret string
|
||||||
Profile *profile.Profile
|
Profile *profile.Profile
|
||||||
Store *store.Store
|
Store *store.Store
|
||||||
@ -34,14 +43,7 @@ func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store
|
|||||||
authProvider.AuthenticationInterceptor,
|
authProvider.AuthenticationInterceptor,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, NewSubscriptionService(profile, store, licenseService))
|
apiV2Service := &APIV2Service{
|
||||||
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,
|
Secret: secret,
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
Store: store,
|
Store: store,
|
||||||
@ -49,6 +51,18 @@ func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store
|
|||||||
grpcServer: grpcServer,
|
grpcServer: grpcServer,
|
||||||
grpcServerPort: grpcServerPort,
|
grpcServerPort: grpcServerPort,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterAuthServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterUserServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterUserSettingServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterShortcutServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterCollectionServiceServer(grpcServer, apiV2Service)
|
||||||
|
apiv2pb.RegisterMemoServiceServer(grpcServer, apiV2Service)
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
return apiV2Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
func (s *APIV2Service) GetGRPCServer() *grpc.Server {
|
||||||
@ -68,13 +82,16 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
gwMux := grpcRuntime.NewServeMux()
|
gwMux := runtime.NewServeMux()
|
||||||
if err := apiv2pb.RegisterSubscriptionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
if err := apiv2pb.RegisterSubscriptionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := apiv2pb.RegisterWorkspaceServiceHandler(context.Background(), gwMux, conn); err != nil {
|
if err := apiv2pb.RegisterWorkspaceServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := apiv2pb.RegisterAuthServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
if err := apiv2pb.RegisterUserServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -84,6 +101,12 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
|
|||||||
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
if err := apiv2pb.RegisterShortcutServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := apiv2pb.RegisterMemoServiceHandler(context.Background(), gwMux, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
e.Any("/api/v2/*", echo.WrapHandler(gwMux))
|
||||||
|
|
||||||
// GRPC web proxy.
|
// GRPC web proxy.
|
||||||
|
@ -6,34 +6,16 @@ import (
|
|||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
apiv2pb "github.com/boojack/slash/proto/gen/api/v2"
|
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||||
storepb "github.com/boojack/slash/proto/gen/store"
|
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/yourselfhosted/slash/store"
|
||||||
"github.com/boojack/slash/server/service/license"
|
|
||||||
"github.com/boojack/slash/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspaceService struct {
|
func (s *APIV2Service) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
||||||
apiv2pb.UnimplementedWorkspaceServiceServer
|
|
||||||
|
|
||||||
Profile *profile.Profile
|
|
||||||
Store *store.Store
|
|
||||||
LicenseService *license.LicenseService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkspaceService creates a new WorkspaceService.
|
|
||||||
func NewWorkspaceService(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *WorkspaceService {
|
|
||||||
return &WorkspaceService{
|
|
||||||
Profile: profile,
|
|
||||||
Store: store,
|
|
||||||
LicenseService: licenseService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WorkspaceService) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) {
|
|
||||||
profile := &apiv2pb.WorkspaceProfile{
|
profile := &apiv2pb.WorkspaceProfile{
|
||||||
Mode: s.Profile.Mode,
|
Mode: s.Profile.Mode,
|
||||||
Plan: apiv2pb.PlanType_FREE,
|
Version: s.Profile.Version,
|
||||||
|
Plan: apiv2pb.PlanType_FREE,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load subscription plan from license service.
|
// Load subscription plan from license service.
|
||||||
@ -58,7 +40,7 @@ func (s *WorkspaceService) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.G
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkspaceService) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWorkspaceSettingRequest) (*apiv2pb.GetWorkspaceSettingResponse, error) {
|
func (s *APIV2Service) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWorkspaceSettingRequest) (*apiv2pb.GetWorkspaceSettingResponse, error) {
|
||||||
isAdmin := false
|
isAdmin := false
|
||||||
userID, ok := ctx.Value(userIDContextKey).(int32)
|
userID, ok := ctx.Value(userIDContextKey).(int32)
|
||||||
if ok {
|
if ok {
|
||||||
@ -80,6 +62,8 @@ func (s *WorkspaceService) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.G
|
|||||||
for _, v := range workspaceSettings {
|
for _, v := range workspaceSettings {
|
||||||
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
if v.Key == storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP {
|
||||||
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
workspaceSetting.EnableSignup = v.GetEnableSignup()
|
||||||
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_INSTANCE_URL {
|
||||||
|
workspaceSetting.InstanceUrl = v.GetInstanceUrl()
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE {
|
||||||
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
workspaceSetting.CustomStyle = v.GetCustomStyle()
|
||||||
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
} else if v.Key == storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_SCRIPT {
|
||||||
@ -96,12 +80,12 @@ func (s *WorkspaceService) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.G
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkspaceService) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
func (s *APIV2Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv2pb.UpdateWorkspaceSettingRequest) (*apiv2pb.UpdateWorkspaceSettingResponse, error) {
|
||||||
if request.UpdateMask == nil || len(request.UpdateMask) == 0 {
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range request.UpdateMask {
|
for _, path := range request.UpdateMask.Paths {
|
||||||
if path == "license_key" {
|
if path == "license_key" {
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY,
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_LICENSE_KEY,
|
||||||
@ -120,11 +104,16 @@ func (s *WorkspaceService) UpdateWorkspaceSetting(ctx context.Context, request *
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
}
|
}
|
||||||
} else if path == "custom_style" {
|
} else if path == "instance_url" {
|
||||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeCustomeStyle) {
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
return nil, status.Errorf(codes.PermissionDenied, "feature custom style is not available")
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_INSTANCE_URL,
|
||||||
|
Value: &storepb.WorkspaceSetting_InstanceUrl{
|
||||||
|
InstanceUrl: request.Setting.InstanceUrl,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to update workspace setting: %v", err)
|
||||||
}
|
}
|
||||||
|
} else if path == "custom_style" {
|
||||||
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
|
||||||
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
Key: storepb.WorkspaceSettingKey_WORKSPACE_SETTING_CUSTOM_STYLE,
|
||||||
Value: &storepb.WorkspaceSetting_CustomStyle{
|
Value: &storepb.WorkspaceSetting_CustomStyle{
|
||||||
|
@ -11,13 +11,13 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/internal/log"
|
"github.com/yourselfhosted/slash/internal/log"
|
||||||
"github.com/boojack/slash/server"
|
"github.com/yourselfhosted/slash/server"
|
||||||
"github.com/boojack/slash/server/profile"
|
"github.com/yourselfhosted/slash/server/metric"
|
||||||
"github.com/boojack/slash/store"
|
"github.com/yourselfhosted/slash/server/profile"
|
||||||
"github.com/boojack/slash/store/db"
|
"github.com/yourselfhosted/slash/store"
|
||||||
|
"github.com/yourselfhosted/slash/store/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -29,20 +29,28 @@ 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 bookmarks and link sharing platform.`,
|
||||||
Run: func(_cmd *cobra.Command, _args []string) {
|
Run: func(_cmd *cobra.Command, _args []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))
|
log.Error("failed to create db driver", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dbDriver.Migrate(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
log.Error("failed to migrate db", zap.Error(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()
|
||||||
@ -50,6 +58,11 @@ var (
|
|||||||
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,
|
||||||
@ -88,6 +101,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 +117,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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,6 +152,7 @@ func initConfig() {
|
|||||||
println("port:", serverProfile.Port)
|
println("port:", serverProfile.Port)
|
||||||
println("mode:", serverProfile.Mode)
|
println("mode:", serverProfile.Mode)
|
||||||
println("version:", serverProfile.Version)
|
println("version:", serverProfile.Version)
|
||||||
|
println("metric:", serverProfile.Metric)
|
||||||
println("---")
|
println("---")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +161,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("---")
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
BIN
docs/assets/demo.png
Normal file
After Width: | Height: | Size: 836 KiB |
BIN
docs/assets/logo.png
Normal file
After Width: | Height: | Size: 19 KiB |
43
docs/getting-started/collections.md
Normal 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.
|
47
docs/getting-started/shortcuts.md
Normal 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.
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 19 KiB |
@ -1,58 +1,61 @@
|
|||||||
{
|
{
|
||||||
"name": "slash-extension",
|
"name": "slash-extension",
|
||||||
"displayName": "Slash",
|
"displayName": "Slash",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
"description": "An open source, self-hosted bookmarks and link 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.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/joy": "5.0.0-beta.0",
|
"@mui/joy": "5.0.0-beta.23",
|
||||||
"@plasmohq/storage": "^1.8.0",
|
"@plasmohq/storage": "^1.9.0",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.6.5",
|
||||||
"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.83.1",
|
||||||
"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.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@trivago/prettier-plugin-sort-imports": "4.1.0",
|
"@bufbuild/buf": "^1.28.1",
|
||||||
"@types/chrome": "0.0.241",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"@types/lodash-es": "^4.17.9",
|
"@types/chrome": "^0.0.241",
|
||||||
"@types/node": "20.4.2",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/react": "18.2.15",
|
"@types/node": "^20.11.5",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react": "^18.2.48",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@typescript-eslint/parser": "^6.7.3",
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"eslint": "^8.50.0",
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.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.33.2",
|
||||||
"long": "^5.2.3",
|
"long": "^5.2.3",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.33",
|
||||||
"prettier": "2.6.2",
|
"prettier": "^2.8.8",
|
||||||
"protobufjs": "^7.2.5",
|
"protobufjs": "^7.2.6",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "5.1.6"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"omnibox": {
|
|
||||||
"keyword": "s"
|
|
||||||
},
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"activeTab",
|
||||||
"storage"
|
"storage",
|
||||||
|
"webRequest"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://*/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2166
frontend/extension/pnpm-lock.yaml
generated
@ -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);
|
||||||
|
@ -1,33 +1,22 @@
|
|||||||
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 { 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 useShortcutStore from "@/store/shortcut";
|
||||||
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/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 [instanceUrl] = useStorage("domain");
|
||||||
const [accessToken] = useStorage("access_token");
|
const [accessToken] = useStorage("access_token");
|
||||||
const [shortcuts, setShortcuts] = useStorage("shortcuts");
|
const shortcutStore = useShortcutStore();
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
name: "",
|
name: "",
|
||||||
title: "",
|
title: "",
|
||||||
@ -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,
|
||||||
@ -93,30 +94,21 @@ const CreateShortcutsButton = () => {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const {
|
await shortcutStore.createShortcut(
|
||||||
data: { shortcut },
|
instanceUrl,
|
||||||
} = await axios.post<CreateShortcutResponse>(
|
accessToken,
|
||||||
`${domain}/api/v2/shortcuts`,
|
Shortcut.fromPartial({
|
||||||
{
|
|
||||||
name: state.name,
|
name: state.name,
|
||||||
title: state.title,
|
title: state.title,
|
||||||
link: state.link,
|
link: state.link,
|
||||||
visibility: Visibility.PRIVATE,
|
visibility: Visibility.PUBLIC,
|
||||||
ogMetadata: OpenGraphMetadata.fromPartial({}),
|
})
|
||||||
},
|
|
||||||
{
|
|
||||||
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) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.details);
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
@ -138,7 +130,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,7 +152,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
@ -170,4 +173,4 @@ const CreateShortcutsButton = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateShortcutsButton;
|
export default CreateShortcutButton;
|
@ -1,12 +1,12 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import LogoBase64 from "data-base64:../..//assets/icon.png";
|
import LogoBase64 from "data-base64:../../assets/icon.png";
|
||||||
|
|
||||||
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 <img className={classNames("rounded-full", className)} src={LogoBase64} alt="" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Logo;
|
export default Logo;
|
||||||
|
@ -1,32 +1,24 @@
|
|||||||
import { IconButton } from "@mui/joy";
|
import { IconButton } from "@mui/joy";
|
||||||
import { useStorage } from "@plasmohq/storage/hook";
|
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 useShortcutStore from "@/store/shortcut";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const PullShortcutsButton = () => {
|
const PullShortcutsButton = () => {
|
||||||
const [domain] = useStorage("domain");
|
const [instanceUrl] = useStorage("domain");
|
||||||
const [accessToken] = useStorage("access_token");
|
const [accessToken] = useStorage("access_token");
|
||||||
const [, setShortcuts] = useStorage("shortcuts");
|
const shortcutStore = useShortcutStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (domain && accessToken) {
|
if (instanceUrl && accessToken) {
|
||||||
handlePullShortcuts(true);
|
handlePullShortcuts(true);
|
||||||
}
|
}
|
||||||
}, [domain, accessToken]);
|
}, [instanceUrl, accessToken]);
|
||||||
|
|
||||||
const handlePullShortcuts = async (silence = false) => {
|
const handlePullShortcuts = async (silence = false) => {
|
||||||
try {
|
try {
|
||||||
const {
|
await shortcutStore.fetchShortcutList(instanceUrl, 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");
|
||||||
}
|
}
|
||||||
|
@ -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/v2/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>
|
||||||
|
@ -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 "@/store/shortcut";
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
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 { useStorage } from "@plasmohq/storage/hook";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@ -8,6 +7,7 @@ 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 useColorTheme from "./hooks/useColorTheme";
|
import useColorTheme from "./hooks/useColorTheme";
|
||||||
|
import useShortcutStore from "./store/shortcut";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
interface SettingState {
|
interface SettingState {
|
||||||
@ -38,7 +38,8 @@ const IndexOptions = () => {
|
|||||||
domain,
|
domain,
|
||||||
accessToken,
|
accessToken,
|
||||||
});
|
});
|
||||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
const shortcutStore = useShortcutStore();
|
||||||
|
const shortcuts = shortcutStore.getShortcutList();
|
||||||
const isInitialized = domain && accessToken;
|
const isInitialized = domain && accessToken;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -66,11 +67,11 @@ const IndexOptions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 +80,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,10 +91,10 @@ 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 !== "" && (
|
{domain !== "" && (
|
||||||
<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={domain}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
@ -106,7 +107,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 +120,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 })}
|
||||||
/>
|
/>
|
||||||
@ -171,7 +172,7 @@ const Options = () => {
|
|||||||
return (
|
return (
|
||||||
<CssVarsProvider>
|
<CssVarsProvider>
|
||||||
<IndexOptions />
|
<IndexOptions />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-center" />
|
||||||
</CssVarsProvider>
|
</CssVarsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,21 +1,31 @@
|
|||||||
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 { 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 useColorTheme from "./hooks/useColorTheme";
|
import useColorTheme from "./hooks/useColorTheme";
|
||||||
|
import useShortcutStore from "./store/shortcut";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
const IndexPopup = () => {
|
const IndexPopup = () => {
|
||||||
useColorTheme();
|
useColorTheme();
|
||||||
const [domain] = useStorage<string>("domain", "");
|
const [instanceUrl] = useStorage<string>("domain", "");
|
||||||
const [accessToken] = useStorage<string>("access_token", "");
|
const [accessToken] = useStorage<string>("access_token", "");
|
||||||
const [shortcuts] = useStorage<Shortcut[]>("shortcuts", []);
|
const shortcutStore = useShortcutStore();
|
||||||
const isInitialized = domain && accessToken;
|
const shortcuts = shortcutStore.getShortcutList();
|
||||||
|
const isInitialized = instanceUrl && accessToken;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcutStore.fetchShortcutList(instanceUrl, accessToken);
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
const handleSettingButtonClick = () => {
|
const handleSettingButtonClick = () => {
|
||||||
chrome.runtime.openOptionsPage();
|
chrome.runtime.openOptionsPage();
|
||||||
@ -30,7 +40,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 +51,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 +72,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={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 +98,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}>
|
||||||
@ -102,7 +119,7 @@ const Popup = () => {
|
|||||||
return (
|
return (
|
||||||
<CssVarsProvider>
|
<CssVarsProvider>
|
||||||
<IndexPopup />
|
<IndexPopup />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-center" />
|
||||||
</CssVarsProvider>
|
</CssVarsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
55
frontend/extension/src/store/shortcut.ts
Normal 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/v2/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/v2/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/v2/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;
|
@ -5,6 +5,7 @@
|
|||||||
<link rel="icon" href="/logo.png" type="image/*" />
|
<link rel="icon" href="/logo.png" 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>
|
||||||
|
@ -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.3",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/joy": "5.0.0-beta.7",
|
"@mui/joy": "5.0.0-beta.23",
|
||||||
"@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.7.18",
|
||||||
"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.2",
|
||||||
"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": "^13.5.0",
|
||||||
"react-redux": "^8.1.2",
|
"react-router-dom": "^6.21.3",
|
||||||
"react-router-dom": "^6.16.0",
|
"react-use": "^17.4.3",
|
||||||
"react-use": "^17.4.0",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss": "^3.3.3",
|
"zustand": "^4.5.0"
|
||||||
"zustand": "^4.4.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
"@bufbuild/buf": "^1.28.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.48",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@typescript-eslint/parser": "^6.7.3",
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.4.0",
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"eslint": "^8.50.0",
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.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.33.2",
|
||||||
"long": "^5.2.3",
|
"long": "^5.2.3",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.33",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"protobufjs": "^7.2.5",
|
"protobufjs": "^7.2.6",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^4.4.9"
|
"vite": "^5.0.12"
|
||||||
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"csstype": "3.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1933
frontend/web/pnpm-lock.yaml
generated
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 19 KiB |
@ -16,7 +16,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);
|
||||||
})();
|
})();
|
||||||
|
@ -25,7 +25,7 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</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>
|
||||||
|
@ -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" />
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
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/v2/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);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -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>
|
||||||
|
@ -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" />
|
||||||
|
135
frontend/web/src/components/CollectionView.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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 from "@/stores/v1/collection";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import useUserStore from "@/stores/v1/user";
|
||||||
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/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;
|
@ -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" />
|
||||||
|
270
frontend/web/src/components/CreateCollectionDrawer.tsx
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
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 useCollectionStore from "@/stores/v1/collection";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
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 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));
|
||||||
|
|
||||||
|
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 setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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: Number(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 sm:max-w-[24rem]">
|
||||||
|
<div className="overflow-y-auto w-full mt-2 px-3 pb-4">
|
||||||
|
<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;
|
@ -1,57 +1,65 @@
|
|||||||
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 useShortcutStore, { getShortcutUpdateMask } from "@/stores/v1/shortcut";
|
||||||
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { shortcutService } from "../services";
|
|
||||||
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 [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);
|
||||||
|
|
||||||
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,14 +69,19 @@ 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]);
|
||||||
|
|
||||||
|
if (loadingState.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const setPartialState = (partialState: Partial<State>) => {
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
@ -103,7 +116,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
visibility: e.target.value,
|
visibility: Number(e.target.value),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -124,8 +137,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 +148,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 +159,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 +176,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 +205,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 sm:max-w-[24rem]">
|
||||||
<Button variant="plain" onClick={onClose}>
|
<div className="overflow-y-auto w-full mt-2 px-3 pb-4">
|
||||||
<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 +283,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 +301,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 +318,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>
|
||||||
@ -337,7 +329,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={state.shortcutCreate.openGraphMetadata.title}
|
value={state.shortcutCreate.ogMetadata?.title}
|
||||||
onChange={handleOpenGraphMetadataTitleChange}
|
onChange={handleOpenGraphMetadataTitleChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -348,26 +340,27 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
placeholder="An open source, self-hosted bookmarks and link 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;
|
@ -3,6 +3,7 @@ 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 { Role, User } from "@/types/proto/api/v2/user_service";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -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>
|
||||||
|
@ -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 bookmarks and link 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
|
||||||
|
@ -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" />
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
import useViewStore from "../stores/v1/view";
|
import useViewStore from "../stores/v1/view";
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
@ -3,6 +3,7 @@ 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 { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import { absolutifyLink } from "../helpers/utils";
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
@ -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" />
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
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 { authServiceClient } from "@/grpcweb";
|
||||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
import * as api from "../helpers/api";
|
import { Role } from "@/types/proto/api/v2/user_service";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -12,24 +13,27 @@ 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 === "/" || location.pathname === "/collections" || location.pathname === "/memos";
|
||||||
|
const selectedSection = location.pathname === "/" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "Memos";
|
||||||
|
|
||||||
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-3 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="" />
|
<img id="logo-img" src="/logo.png" className="w-7 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
|
||||||
Slash
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
{profile.plan === PlanType.PRO && (
|
{profile.plan === PlanType.PRO && (
|
||||||
@ -37,6 +41,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="/"
|
||||||
|
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 +87,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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
59
frontend/web/src/components/ResourceNameInput.tsx
Normal 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;
|
@ -1,10 +1,12 @@
|
|||||||
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 from "@/stores/v1/shortcut";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import { Role } from "@/types/proto/api/v2/user_service";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
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 +19,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 +31,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 +43,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 +73,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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
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 useUserStore from "@/stores/v1/user";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
import useViewStore from "../stores/v1/view";
|
import useViewStore from "../stores/v1/view";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -14,123 +18,139 @@ 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);
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userStore.getOrFetchUserById(shortcut.creatorId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopyButtonClick = () => {
|
const handleCopyButtonClick = () => {
|
||||||
copy(shortcutLink);
|
copy(shortcutLink);
|
||||||
toast.success("Shortcut link copied to clipboard.");
|
toast.success("Shortcut link copied to clipboard.");
|
||||||
};
|
};
|
||||||
|
|
||||||
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" />
|
{favicon ? (
|
||||||
)}
|
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
|
||||||
</Link>
|
) : (
|
||||||
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
<div className="w-full flex flex-row justify-start items-center">
|
)}
|
||||||
<a
|
</Link>
|
||||||
className={classNames(
|
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||||
"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"
|
<div className="w-full flex flex-row justify-start items-center">
|
||||||
)}
|
|
||||||
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 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"
|
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 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>
|
</div>
|
||||||
</div>
|
<a
|
||||||
<div className="h-full pt-2 flex flex-row justify-end items-start">
|
className="pl-1 pr-4 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;
|
||||||
|
41
frontend/web/src/components/ShortcutFrame.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Divider } from "@mui/joy";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutFrame = ({ shortcut }: Props) => {
|
||||||
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
|
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")}>
|
||||||
|
{favicon ? (
|
||||||
|
<img className="w-full h-auto" src={favicon} decoding="async" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon.Globe2Icon className="w-full h-auto opacity-70" strokeWidth={1} />
|
||||||
|
)}
|
||||||
|
</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;
|
@ -1,67 +1,66 @@
|
|||||||
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/v2/shortcut_service";
|
||||||
|
import { getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
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);
|
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={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||||
|
{favicon ? (
|
||||||
|
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<Link to={`/shortcut/${shortcut.id}`} className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
|
||||||
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
import useViewStore from "../stores/v1/view";
|
import useViewStore from "../stores/v1/view";
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
70
frontend/web/src/components/ShortcutsNavigator.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
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;
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Visibility } from "@/types/proto/api/v2/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;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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 { Role } from "@/types/proto/api/v2/user_service";
|
||||||
import useUserStore from "../../stores/v1/user";
|
import useUserStore from "../../stores/v1/user";
|
||||||
import ChangePasswordDialog from "../ChangePasswordDialog";
|
import ChangePasswordDialog from "../ChangePasswordDialog";
|
||||||
import EditUserinfoDialog from "../EditUserinfoDialog";
|
import EditUserinfoDialog from "../EditUserinfoDialog";
|
||||||
@ -10,7 +11,7 @@ const AccountSection: React.FC = () => {
|
|||||||
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 (
|
||||||
<>
|
<>
|
||||||
|
@ -2,6 +2,8 @@ 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 { User } from "@/types/proto/api/v2/user_service";
|
||||||
|
import { convertRoleFromPb } from "@/utils/user";
|
||||||
import useUserStore from "../../stores/v1/user";
|
import useUserStore from "../../stores/v1/user";
|
||||||
import { showCommonDialog } from "../Alert";
|
import { showCommonDialog } from "../Alert";
|
||||||
import CreateUserDialog from "../CreateUserDialog";
|
import CreateUserDialog from "../CreateUserDialog";
|
||||||
@ -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}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -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"
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Button, Checkbox, Textarea } from "@mui/joy";
|
import { Button, Checkbox, Input, Textarea } 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";
|
||||||
@ -21,6 +21,13 @@ const WorkspaceSection: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInstanceUrlChange = async (value: string) => {
|
||||||
|
setWorkspaceSetting({
|
||||||
|
...workspaceSetting,
|
||||||
|
instanceUrl: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleCustomStyleChange = async (value: string) => {
|
const handleCustomStyleChange = async (value: string) => {
|
||||||
setWorkspaceSetting({
|
setWorkspaceSetting({
|
||||||
...workspaceSetting,
|
...workspaceSetting,
|
||||||
@ -33,6 +40,9 @@ const WorkspaceSection: React.FC = () => {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
@ -59,10 +69,20 @@ 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 justify-start items-start space-y-4">
|
||||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
|
<p className="mt-2 dark:text-gray-400">Instance URL</p>
|
||||||
|
<Input
|
||||||
|
className="w-full mt-2"
|
||||||
|
placeholder="Your instance URL. Using for website SEO. Leave it empty if you don't want cawler to index your website."
|
||||||
|
value={workspaceSetting.instanceUrl}
|
||||||
|
onChange={(event) => handleInstanceUrlChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<p className="mt-2 dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
|
<p className="mt-2 dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="w-full mt-2"
|
className="w-full mt-2"
|
||||||
|
placeholder="* {font-family: ui-monospace Monaco Consolas;}"
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={5}
|
maxRows={5}
|
||||||
value={workspaceSetting.customStyle}
|
value={workspaceSetting.customStyle}
|
||||||
|
3
frontend/web/src/css/joy-ui.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.MuiDrawer-content {
|
||||||
|
@apply !w-auto;
|
||||||
|
}
|
@ -1,4 +1,7 @@
|
|||||||
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
|
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
|
||||||
|
import { AuthServiceDefinition } from "./types/proto/api/v2/auth_service";
|
||||||
|
import { CollectionServiceDefinition } from "./types/proto/api/v2/collection_service";
|
||||||
|
import { ShortcutServiceDefinition } from "./types/proto/api/v2/shortcut_service";
|
||||||
import { SubscriptionServiceDefinition } from "./types/proto/api/v2/subscription_service";
|
import { SubscriptionServiceDefinition } from "./types/proto/api/v2/subscription_service";
|
||||||
import { UserServiceDefinition } from "./types/proto/api/v2/user_service";
|
import { UserServiceDefinition } from "./types/proto/api/v2/user_service";
|
||||||
import { UserSettingServiceDefinition } from "./types/proto/api/v2/user_setting_service";
|
import { UserSettingServiceDefinition } from "./types/proto/api/v2/user_setting_service";
|
||||||
@ -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);
|
||||||
|
@ -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}`);
|
|
||||||
}
|
|
@ -10,6 +10,11 @@ export const absolutifyLink = (rel: string): string => {
|
|||||||
return anchor.href;
|
return anchor.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isURL = (str: string): boolean => {
|
||||||
|
const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i;
|
||||||
|
return urlRegex.test(str);
|
||||||
|
};
|
||||||
|
|
||||||
export const releaseGuard = () => {
|
export const releaseGuard = () => {
|
||||||
return import.meta.env.MODE === "development";
|
return import.meta.env.MODE === "development";
|
||||||
};
|
};
|
||||||
@ -22,3 +27,13 @@ export const getFaviconWithGoogleS2 = (url: string) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateRandomString = () => {
|
||||||
|
const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
let randomString = "";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * characters.length);
|
||||||
|
randomString += characters.charAt(randomIndex);
|
||||||
|
}
|
||||||
|
return randomString;
|
||||||
|
};
|
||||||
|
20
frontend/web/src/hooks/useResponsiveWidth.ts
Normal 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;
|
@ -3,6 +3,7 @@ import { isEqual } from "lodash-es";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import Navigator from "@/components/Navigator";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
@ -50,14 +51,13 @@ const Root: React.FC = () => {
|
|||||||
}, [currentUserSetting]);
|
}, [currentUserSetting]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
isInitialized && (
|
||||||
{isInitialized && (
|
<div className="w-full h-auto flex flex-col justify-start items-start dark:bg-zinc-900">
|
||||||
<div className="w-full h-auto flex flex-col justify-start items-start dark:bg-zinc-900">
|
<Header />
|
||||||
<Header />
|
<Navigator />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,21 +1,18 @@
|
|||||||
import { CssVarsProvider } from "@mui/joy";
|
import { CssVarsProvider } from "@mui/joy";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { Provider } from "react-redux";
|
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import "./css/index.css";
|
import "./css/index.css";
|
||||||
|
import "./css/joy-ui.css";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import router from "./routers";
|
import router from "./routers";
|
||||||
import store from "./stores";
|
|
||||||
|
|
||||||
const container = document.getElementById("root");
|
const container = document.getElementById("root");
|
||||||
const root = createRoot(container as HTMLElement);
|
const root = createRoot(container as HTMLElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<Provider store={store}>
|
<CssVarsProvider>
|
||||||
<CssVarsProvider>
|
<RouterProvider router={router} />
|
||||||
<RouterProvider router={router} />
|
<Toaster position="top-center" />
|
||||||
<Toaster position="top-center" />
|
</CssVarsProvider>
|
||||||
</CssVarsProvider>
|
|
||||||
</Provider>
|
|
||||||
);
|
);
|
||||||
|
98
frontend/web/src/pages/CollectionDashboard.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { Button, Input } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import CollectionView from "@/components/CollectionView";
|
||||||
|
import CreateCollectionDrawer from "@/components/CreateCollectionDrawer";
|
||||||
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import FilterView from "../components/FilterView";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
showCreateCollectionDrawer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionDashboard: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const loadingState = useLoading();
|
||||||
|
const shortcutStore = useShortcutStore();
|
||||||
|
const collectionStore = useCollectionStore();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
showCreateCollectionDrawer: false,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const filteredCollections = collectionStore.getCollectionList().filter((collection) => {
|
||||||
|
return (
|
||||||
|
collection.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
collection.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
collection.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([shortcutStore.fetchShortcutList(), collectionStore.fetchCollectionList()]).finally(() => {
|
||||||
|
loadingState.setFinish();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShowCreateCollectionDrawer = (show: boolean) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
showCreateCollectionDrawer: show,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className="w-32 mr-2"
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
placeholder={t("common.search")}
|
||||||
|
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-start items-center">
|
||||||
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateCollectionDrawer(true)}>
|
||||||
|
<Icon.Plus className="w-5 h-auto" />
|
||||||
|
<span className="ml-0.5">{t("common.create")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FilterView />
|
||||||
|
{loadingState.isLoading ? (
|
||||||
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80 dark:text-gray-500">
|
||||||
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : filteredCollections.length === 0 ? (
|
||||||
|
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
|
||||||
|
<p className="mt-4">No collections found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start gap-3">
|
||||||
|
{filteredCollections.map((collection) => {
|
||||||
|
return <CollectionView key={collection.id} collection={collection} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.showCreateCollectionDrawer && (
|
||||||
|
<CreateCollectionDrawer
|
||||||
|
onClose={() => setShowCreateCollectionDrawer(false)}
|
||||||
|
onConfirm={() => setShowCreateCollectionDrawer(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionDashboard;
|
123
frontend/web/src/pages/CollectionSpace.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Divider } from "@mui/joy";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
import ShortcutFrame from "@/components/ShortcutFrame";
|
||||||
|
import ShortcutView from "@/components/ShortcutView";
|
||||||
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import useUserStore from "@/stores/v1/user";
|
||||||
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
|
||||||
|
const CollectionSpace = () => {
|
||||||
|
const params = useParams();
|
||||||
|
const collectionName = params["*"];
|
||||||
|
const { sm } = useResponsiveWidth();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const collectionStore = useCollectionStore();
|
||||||
|
const shortcutStore = useShortcutStore();
|
||||||
|
const [collection, setCollection] = useState<Collection>();
|
||||||
|
const [shortcuts, setShortcuts] = useState<Shortcut[]>([]);
|
||||||
|
const [selectedShortcut, setSelectedShortcut] = useState<Shortcut>();
|
||||||
|
|
||||||
|
if (!collectionName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const collection = await collectionStore.fetchCollectionByName(collectionName);
|
||||||
|
setCollection(collection);
|
||||||
|
setShortcuts([]);
|
||||||
|
for (const shortcutId of collection.shortcutIds) {
|
||||||
|
try {
|
||||||
|
const shortcut = await shortcutStore.getOrFetchShortcutById(shortcutId);
|
||||||
|
setShortcuts((shortcuts) => {
|
||||||
|
return [...shortcuts, shortcut];
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Do nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.title = `${collection.title} - Slash`;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [collectionName]);
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const creator = userStore.getUserById(collection.creatorId);
|
||||||
|
|
||||||
|
const handleShortcutClick = (shortcut: Shortcut) => {
|
||||||
|
if (sm) {
|
||||||
|
setSelectedShortcut(shortcut);
|
||||||
|
} else {
|
||||||
|
window.open(`/s/${shortcut.name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full sm:px-12 sm:py-10 sm:h-screen sm:bg-gray-100 dark:sm:bg-zinc-800">
|
||||||
|
<div className="w-full h-full flex flex-row sm:border dark:sm:border-zinc-800 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900">
|
||||||
|
<div className="w-full sm:w-56 sm:pr-4 flex flex-col justify-start items-start overflow-auto shrink-0">
|
||||||
|
<div className="w-full sticky top-0 px-2">
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-gray-800 dark:text-gray-300">
|
||||||
|
<Icon.LibrarySquare className="w-5 h-auto mr-1 opacity-70 shrink-0" />
|
||||||
|
<span className="text-lg truncate">{collection.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm truncate">{collection.description}</p>
|
||||||
|
</div>
|
||||||
|
<Divider className="!my-2" />
|
||||||
|
<div className="w-full flex flex-col justify-start items-start gap-2 sm:gap-1 px-px">
|
||||||
|
{shortcuts.map((shortcut) => {
|
||||||
|
return (
|
||||||
|
<ShortcutView
|
||||||
|
className={classNames(
|
||||||
|
"w-full py-2 cursor-pointer sm:!px-2",
|
||||||
|
selectedShortcut?.id === shortcut.id
|
||||||
|
? "bg-gray-100 dark:bg-zinc-800"
|
||||||
|
: "sm:border-transparent dark:sm:border-transparent"
|
||||||
|
)}
|
||||||
|
key={shortcut.name}
|
||||||
|
shortcut={shortcut}
|
||||||
|
alwaysShowLink={!sm}
|
||||||
|
onClick={() => handleShortcutClick(shortcut)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sm && (
|
||||||
|
<div className="w-full h-full overflow-clip rounded-lg border dark:border-zinc-800 bg-white dark:bg-zinc-800">
|
||||||
|
{selectedShortcut ? (
|
||||||
|
<ShortcutFrame key={selectedShortcut.id} shortcut={selectedShortcut} />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center p-8">
|
||||||
|
<div className="w-72 max-w-full border dark:border-zinc-900 dark:bg-zinc-900 dark:text-gray-400 p-6 pb-4 rounded-2xl shadow-xl">
|
||||||
|
<Icon.AppWindow className="w-12 h-auto mb-2 opacity-60" strokeWidth={1} />
|
||||||
|
<p className="text-lg font-medium">Click on a tab in the Sidebar to get started.</p>
|
||||||
|
<Divider className="!my-2" />
|
||||||
|
<p className="text-gray-400 dark:text-gray-600 text-sm mt-2 italic">
|
||||||
|
Shared by <span className="font-medium not-italic">{creator.nickname}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionSpace;
|
@ -1,62 +1,56 @@
|
|||||||
import { Button, Input } from "@mui/joy";
|
import { Button, Input } from "@mui/joy";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import CreateShortcutDrawer from "../components/CreateShortcutDrawer";
|
||||||
import FilterView from "../components/FilterView";
|
import FilterView from "../components/FilterView";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import Navigator from "../components/Navigator";
|
|
||||||
import ShortcutsContainer from "../components/ShortcutsContainer";
|
import ShortcutsContainer from "../components/ShortcutsContainer";
|
||||||
|
import ShortcutsNavigator from "../components/ShortcutsNavigator";
|
||||||
import ViewSetting from "../components/ViewSetting";
|
import ViewSetting from "../components/ViewSetting";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { shortcutService } from "../services";
|
|
||||||
import { useAppSelector } from "../stores";
|
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
showCreateShortcutDialog: boolean;
|
showCreateShortcutDrawer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const loadingState = useLoading();
|
const loadingState = useLoading();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const shortcutStore = useShortcutStore();
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
const shortcutList = shortcutStore.getShortcutList();
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
showCreateShortcutDialog: false,
|
showCreateShortcutDrawer: false,
|
||||||
});
|
});
|
||||||
const filter = viewStore.filter;
|
const filter = viewStore.filter;
|
||||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||||
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
Promise.all([shortcutStore.fetchShortcutList()]).finally(() => {
|
||||||
loadingState.setFinish();
|
loadingState.setFinish();
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setShowCreateShortcutDialog = (show: boolean) => {
|
const setShowCreateShortcutDrawer = (show: boolean) => {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
showCreateShortcutDialog: show,
|
showCreateShortcutDrawer: show,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
<Navigator />
|
<ShortcutsNavigator />
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
|
||||||
<Icon.Plus className="w-5 h-auto" />
|
|
||||||
<span className="hidden sm:block ml-0.5">{t("common.create")}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row justify-end items-center">
|
|
||||||
<Input
|
<Input
|
||||||
className="w-32 ml-2"
|
className="w-32 mr-2"
|
||||||
type="text"
|
type="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder={t("common.search")}
|
placeholder={t("common.search")}
|
||||||
@ -66,6 +60,12 @@ const Home: React.FC = () => {
|
|||||||
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-row justify-end items-center">
|
||||||
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDrawer(true)}>
|
||||||
|
<Icon.Plus className="w-5 h-auto" />
|
||||||
|
<span className="ml-0.5">{t("common.create")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FilterView />
|
<FilterView />
|
||||||
{loadingState.isLoading ? (
|
{loadingState.isLoading ? (
|
||||||
@ -83,8 +83,8 @@ const Home: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.showCreateShortcutDialog && (
|
{state.showCreateShortcutDrawer && (
|
||||||
<CreateShortcutDialog onClose={() => setShowCreateShortcutDialog(false)} onConfirm={() => setShowCreateShortcutDialog(false)} />
|
<CreateShortcutDrawer onClose={() => setShowCreateShortcutDrawer(false)} onConfirm={() => setShowCreateShortcutDrawer(false)} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
96
frontend/web/src/pages/MemoDashboard.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Button, Input } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import CollectionView from "@/components/CollectionView";
|
||||||
|
import CreateCollectionDrawer from "@/components/CreateCollectionDrawer";
|
||||||
|
import useCollectionStore from "@/stores/v1/collection";
|
||||||
|
import FilterView from "../components/FilterView";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
showCreateCollectionDrawer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoDashboard: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const loadingState = useLoading();
|
||||||
|
const collectionStore = useCollectionStore();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
showCreateCollectionDrawer: false,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const filteredCollections = collectionStore.getCollectionList().filter((collection) => {
|
||||||
|
return (
|
||||||
|
collection.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
collection.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
collection.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([collectionStore.fetchCollectionList()]).finally(() => {
|
||||||
|
loadingState.setFinish();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShowCreateCollectionDrawer = (show: boolean) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
showCreateCollectionDrawer: show,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className="w-32 mr-2"
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
placeholder={t("common.search")}
|
||||||
|
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-start items-center">
|
||||||
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateCollectionDrawer(true)}>
|
||||||
|
<Icon.Plus className="w-5 h-auto" />
|
||||||
|
<span className="ml-0.5">{t("common.create")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FilterView />
|
||||||
|
{loadingState.isLoading ? (
|
||||||
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80 dark:text-gray-500">
|
||||||
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : filteredCollections.length === 0 ? (
|
||||||
|
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
|
||||||
|
<p className="mt-4">No collections found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start gap-3">
|
||||||
|
{filteredCollections.map((collection) => {
|
||||||
|
return <CollectionView key={collection.id} collection={collection} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.showCreateCollectionDrawer && (
|
||||||
|
<CreateCollectionDrawer
|
||||||
|
onClose={() => setShowCreateCollectionDrawer(false)}
|
||||||
|
onConfirm={() => setShowCreateCollectionDrawer(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MemoDashboard;
|
@ -1,61 +1,13 @@
|
|||||||
import { Button } from "@mui/joy";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import CreateShortcutDialog from "@/components/CreateShortcutDialog";
|
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
|
||||||
import useUserStore from "@/stores/v1/user";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
showCreateShortcutButton: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotFound = () => {
|
const NotFound = () => {
|
||||||
const location = useLocation();
|
|
||||||
const navigateTo = useNavigateTo();
|
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
|
||||||
const [state, setState] = useState<State>({
|
|
||||||
showCreateShortcutButton: false,
|
|
||||||
});
|
|
||||||
const [showCreateShortcutDialog, setShowCreateShortcutDialog] = useState(false);
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const shortcut = params.get("shortcut");
|
|
||||||
if (currentUser && shortcut) {
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
showCreateShortcutButton: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="w-full h-full overflow-y-auto overflow-x-hidden bg-zinc-100 dark:bg-zinc-800">
|
||||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden bg-zinc-100 dark:bg-zinc-800">
|
<div className="w-full h-full flex flex-col justify-center items-center">
|
||||||
<div className="w-full h-full flex flex-col justify-center items-center">
|
<Icon.Meh strokeWidth={1} className="w-20 h-auto opacity-80 dark:text-gray-300" />
|
||||||
<Icon.Meh strokeWidth={1} className="w-20 h-auto opacity-80 dark:text-gray-300" />
|
<p className="mt-4 mb-8 text-4xl font-mono dark:text-gray-300">404</p>
|
||||||
<p className="mt-4 mb-8 text-4xl font-mono dark:text-gray-300">404</p>
|
|
||||||
{state.showCreateShortcutButton && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startDecorator={<Icon.Plus className="w-5 h-auto" />}
|
|
||||||
onClick={() => setShowCreateShortcutDialog(true)}
|
|
||||||
>
|
|
||||||
Create shortcut
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{showCreateShortcutDialog && (
|
|
||||||
<CreateShortcutDialog
|
|
||||||
initialShortcut={{ name: params.get("shortcut") || "" }}
|
|
||||||
onClose={() => setShowCreateShortcutDialog(false)}
|
|
||||||
onConfirm={() => navigateTo("/")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,40 +1,61 @@
|
|||||||
import { Tooltip } from "@mui/joy";
|
import { 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 { 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 { useLoaderData } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
import { Role } from "@/types/proto/api/v2/user_service";
|
||||||
|
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||||
import { showCommonDialog } from "../components/Alert";
|
import { showCommonDialog } from "../components/Alert";
|
||||||
import AnalyticsView from "../components/AnalyticsView";
|
import AnalyticsView from "../components/AnalyticsView";
|
||||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
import CreateShortcutDrawer from "../components/CreateShortcutDrawer";
|
||||||
import GenerateQRCodeDialog from "../components/GenerateQRCodeDialog";
|
import GenerateQRCodeDialog from "../components/GenerateQRCodeDialog";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import VisibilityIcon from "../components/VisibilityIcon";
|
import VisibilityIcon from "../components/VisibilityIcon";
|
||||||
import Dropdown from "../components/common/Dropdown";
|
import Dropdown from "../components/common/Dropdown";
|
||||||
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||||
import { shortcutService } from "../services";
|
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
showEditModal: boolean;
|
showEditDrawer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutDetail = () => {
|
const ShortcutDetail = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const params = useParams();
|
||||||
|
const shortcutId = Number(params["shortcutId"]);
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const shortcutId = (useLoaderData() as Shortcut).id;
|
const shortcutStore = useShortcutStore();
|
||||||
const shortcut = shortcutService.getShortcutById(shortcutId) as Shortcut;
|
const userStore = useUserStore();
|
||||||
|
const shortcut = shortcutStore.getShortcutById(shortcutId);
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
showEditModal: false,
|
showEditDrawer: false,
|
||||||
});
|
});
|
||||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
const loadingState = useLoading(true);
|
||||||
|
const creator = userStore.getUserById(shortcut.creatorId);
|
||||||
|
const havePermission = currentUser.role === Role.ADMIN || shortcut.creatorId === currentUser.id;
|
||||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const shortcut = await shortcutStore.getOrFetchShortcutById(shortcutId);
|
||||||
|
await userStore.getOrFetchUserById(shortcut.creatorId);
|
||||||
|
loadingState.setFinish();
|
||||||
|
})();
|
||||||
|
}, [shortcutId]);
|
||||||
|
|
||||||
|
if (loadingState.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleCopyButtonClick = () => {
|
const handleCopyButtonClick = () => {
|
||||||
copy(shortcutLink);
|
copy(shortcutLink);
|
||||||
toast.success("Shortcut link copied to clipboard.");
|
toast.success("Shortcut link copied to clipboard.");
|
||||||
@ -46,7 +67,7 @@ const ShortcutDetail = () => {
|
|||||||
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);
|
||||||
navigateTo("/", {
|
navigateTo("/", {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
@ -56,12 +77,12 @@ const ShortcutDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
<div className="mt-8 w-12 h-12 flex justify-center items-center overflow-clip">
|
<div className="mt-4 sm:mt-8 w-12 h-12 flex justify-center items-center overflow-clip">
|
||||||
{favicon ? (
|
{favicon ? (
|
||||||
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
<img className="w-full h-auto rounded-lg" 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" strokeWidth={1} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
@ -72,9 +93,11 @@ const ShortcutDetail = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<div className="truncate text-3xl">
|
<div className="truncate text-3xl">
|
||||||
<span>{shortcut.title}</span>
|
|
||||||
{shortcut.title ? (
|
{shortcut.title ? (
|
||||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
<>
|
||||||
|
<span>{shortcut.title}</span>
|
||||||
|
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
<span className="text-gray-400 dark:text-gray-500">s/</span>
|
||||||
@ -114,7 +137,7 @@ const ShortcutDetail = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
showEditModal: true,
|
showEditDrawer: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -133,36 +156,39 @@ const ShortcutDetail = () => {
|
|||||||
></Dropdown>
|
></Dropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{shortcut.description && <p className="w-full break-all mt-2 text-gray-400 text-sm dark:text-gray-500">{shortcut.description}</p>}
|
{shortcut.description && <p className="w-full break-all mt-4 text-gray-500 dark:text-gray-400">{shortcut.description}</p>}
|
||||||
<div className="mt-4 ml-1 flex flex-row justify-start items-start flex-wrap gap-2">
|
<div className="mt-2 flex flex-row justify-start items-start flex-wrap gap-2">
|
||||||
{shortcut.tags.map((tag) => {
|
{shortcut.tags.map((tag) => {
|
||||||
return (
|
return (
|
||||||
<span key={tag} className="max-w-[8rem] truncate text-gray-400 text font-mono leading-4 dark:text-gray-500">
|
<span key={tag} className="max-w-[8rem] truncate text-gray-400 text leading-4 dark:text-gray-500">
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{shortcut.tags.length === 0 && (
|
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm leading-4 italic dark:text-gray-500">No tags</span>}
|
||||||
<span className="text-gray-400 text-sm font-mono leading-4 italic dark:text-gray-500">No tags</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex mt-4 gap-2">
|
<div className="w-full flex mt-4 gap-2">
|
||||||
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
||||||
<Icon.User className="w-4 h-auto mr-1" />
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
|
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{creator.nickname}</span>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
<Tooltip
|
||||||
|
title={t(`shortcut.visibility.${convertVisibilityFromPb(shortcut.visibility).toLowerCase()}.description`)}
|
||||||
|
variant="solid"
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
||||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
||||||
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
{t(`shortcut.visibility.${convertVisibilityFromPb(shortcut.visibility).toLowerCase()}.self`)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm dark:border-zinc-800">
|
||||||
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||||
{shortcut.view} visits
|
{shortcut.viewCount} visits
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -178,13 +204,13 @@ const ShortcutDetail = () => {
|
|||||||
|
|
||||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||||
|
|
||||||
{state.showEditModal && (
|
{state.showEditDrawer && (
|
||||||
<CreateShortcutDialog
|
<CreateShortcutDrawer
|
||||||
shortcutId={shortcut.id}
|
shortcutId={shortcut.id}
|
||||||
onClose={() =>
|
onClose={() =>
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
showEditModal: false,
|
showEditDrawer: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
81
frontend/web/src/pages/ShortcutSpace.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Button } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import CreateShortcutDrawer from "@/components/CreateShortcutDrawer";
|
||||||
|
import { isURL } from "@/helpers/utils";
|
||||||
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import useShortcutStore from "@/stores/v1/shortcut";
|
||||||
|
import useUserStore from "@/stores/v1/user";
|
||||||
|
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
|
||||||
|
const ShortcutSpace = () => {
|
||||||
|
const params = useParams();
|
||||||
|
const shortcutName = params["*"] || "";
|
||||||
|
const navigateTo = useNavigateTo();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const currentUser = userStore.getCurrentUser();
|
||||||
|
const shortcutStore = useShortcutStore();
|
||||||
|
const [shortcut, setShortcut] = useState<Shortcut>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreateShortcutDrawer, setShowCreateShortcutDrawer] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const shortcut = await shortcutStore.fetchShortcutByName(shortcutName);
|
||||||
|
setShortcut(shortcut);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, [shortcutName]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shortcut) {
|
||||||
|
if (!currentUser) {
|
||||||
|
navigateTo("/404");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If shortcut is not found, prompt user to create it.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-[100svh] flex flex-col justify-center items-center p-4">
|
||||||
|
<p className="text-xl">
|
||||||
|
Shortcut <span className="font-mono">{shortcutName}</span> Not Found.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="plain" size="sm" onClick={() => setShowCreateShortcutDrawer(true)}>
|
||||||
|
👉 Click here to create it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showCreateShortcutDrawer && (
|
||||||
|
<CreateShortcutDrawer
|
||||||
|
initialShortcut={{ name: shortcutName }}
|
||||||
|
onClose={() => setShowCreateShortcutDrawer(false)}
|
||||||
|
onConfirm={() => navigateTo("/")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If shortcut is a URL, redirect to it directly.
|
||||||
|
if (isURL(shortcut.link)) {
|
||||||
|
window.document.title = "Redirecting...";
|
||||||
|
window.location.href = shortcut.link;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, render the shortcut link as plain text.
|
||||||
|
return <div>{shortcut.link}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutSpace;
|
@ -3,29 +3,23 @@ import React, { FormEvent, 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 { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { authServiceClient } from "@/grpcweb";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import useUserStore from "@/stores/v1/user";
|
||||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import * as api from "../helpers/api";
|
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
|
||||||
|
|
||||||
const SignIn: React.FC = () => {
|
const SignIn: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const userStore = useUserStore();
|
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const actionBtnLoadingState = useLoading(false);
|
const actionBtnLoadingState = useLoading(false);
|
||||||
const allowConfirm = email.length > 0 && password.length > 0;
|
const allowConfirm = email.length > 0 && password.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userStore.getCurrentUser()) {
|
|
||||||
return navigateTo("/", {
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspaceStore.profile.mode === "demo") {
|
if (workspaceStore.profile.mode === "demo") {
|
||||||
setEmail("steven@yourselfhosted.com");
|
setEmail("steven@yourselfhosted.com");
|
||||||
setPassword("secret");
|
setPassword("secret");
|
||||||
@ -50,18 +44,17 @@ const SignIn: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
actionBtnLoadingState.setLoading();
|
actionBtnLoadingState.setLoading();
|
||||||
await api.signin(email, password);
|
const { user } = await authServiceClient.signIn({ email, password });
|
||||||
const user = await userStore.fetchCurrentUser();
|
|
||||||
if (user) {
|
if (user) {
|
||||||
navigateTo("/", {
|
userStore.setCurrentUserId(user.id);
|
||||||
replace: true,
|
await userStore.fetchCurrentUser();
|
||||||
});
|
navigateTo("/");
|
||||||
} else {
|
} else {
|
||||||
toast.error("Signin failed");
|
toast.error("Signin failed");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.details);
|
||||||
}
|
}
|
||||||
actionBtnLoadingState.setFinish();
|
actionBtnLoadingState.setFinish();
|
||||||
};
|
};
|
||||||
@ -71,7 +64,7 @@ const SignIn: React.FC = () => {
|
|||||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||||
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1 rounded-full shadow" alt="logo" />
|
||||||
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
||||||
</div>
|
</div>
|
||||||
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
||||||
@ -107,7 +100,7 @@ const SignIn: React.FC = () => {
|
|||||||
{workspaceStore.profile.enableSignup && (
|
{workspaceStore.profile.enableSignup && (
|
||||||
<p className="w-full mt-4 text-sm">
|
<p className="w-full mt-4 text-sm">
|
||||||
<span className="dark:text-gray-500">{"Don't have an account yet?"}</span>
|
<span className="dark:text-gray-500">{"Don't have an account yet?"}</span>
|
||||||
<Link to="/auth/signup" className="cursor-pointer ml-2 text-blue-600 hover:underline">
|
<Link className="cursor-pointer ml-2 text-blue-600 hover:underline" to="/auth/signup" unstable_viewTransition>
|
||||||
{t("auth.sign-up")}
|
{t("auth.sign-up")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
@ -3,17 +3,17 @@ import React, { FormEvent, 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 { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { authServiceClient } from "@/grpcweb";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
|
import useUserStore from "@/stores/v1/user";
|
||||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import * as api from "../helpers/api";
|
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
|
||||||
|
|
||||||
const SignUp: React.FC = () => {
|
const SignUp: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigateTo = useNavigateTo();
|
const navigateTo = useNavigateTo();
|
||||||
const userStore = useUserStore();
|
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [nickname, setNickname] = useState("");
|
const [nickname, setNickname] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -21,12 +21,6 @@ const SignUp: React.FC = () => {
|
|||||||
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
|
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userStore.getCurrentUser()) {
|
|
||||||
return navigateTo("/", {
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaceStore.profile.enableSignup) {
|
if (!workspaceStore.profile.enableSignup) {
|
||||||
return navigateTo("/auth", {
|
return navigateTo("/auth", {
|
||||||
replace: true,
|
replace: true,
|
||||||
@ -57,18 +51,21 @@ const SignUp: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
actionBtnLoadingState.setLoading();
|
actionBtnLoadingState.setLoading();
|
||||||
await api.signup(email, nickname, password);
|
const { user } = await authServiceClient.signUp({
|
||||||
const user = await userStore.fetchCurrentUser();
|
email,
|
||||||
|
nickname,
|
||||||
|
password,
|
||||||
|
});
|
||||||
if (user) {
|
if (user) {
|
||||||
navigateTo("/", {
|
userStore.setCurrentUserId(user.id);
|
||||||
replace: true,
|
await userStore.fetchCurrentUser();
|
||||||
});
|
navigateTo("/");
|
||||||
} else {
|
} else {
|
||||||
toast.error("Signup failed");
|
toast.error("Signup failed");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.details);
|
||||||
}
|
}
|
||||||
actionBtnLoadingState.setFinish();
|
actionBtnLoadingState.setFinish();
|
||||||
};
|
};
|
||||||
@ -78,7 +75,7 @@ const SignUp: React.FC = () => {
|
|||||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||||
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1 rounded-full shadow" alt="logo" />
|
||||||
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="w-full text-2xl mt-6 dark:text-gray-500">{t("auth.create-your-account")}</p>
|
<p className="w-full text-2xl mt-6 dark:text-gray-500">{t("auth.create-your-account")}</p>
|
||||||
@ -118,7 +115,7 @@ const SignUp: React.FC = () => {
|
|||||||
</form>
|
</form>
|
||||||
<p className="w-full mt-4 text-sm">
|
<p className="w-full mt-4 text-sm">
|
||||||
<span className="dark:text-gray-500">{"Already has an account?"}</span>
|
<span className="dark:text-gray-500">{"Already has an account?"}</span>
|
||||||
<Link to="/auth" className="cursor-pointer ml-2 text-blue-600 hover:underline">
|
<Link className="cursor-pointer ml-2 text-blue-600 hover:underline" to="/auth" unstable_viewTransition>
|
||||||
{t("auth.sign-in")}
|
{t("auth.sign-in")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
@ -7,13 +7,14 @@ import { subscriptionServiceClient } from "@/grpcweb";
|
|||||||
import { stringifyPlanType } from "@/stores/v1/subscription";
|
import { stringifyPlanType } from "@/stores/v1/subscription";
|
||||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
|
import { Role } from "@/types/proto/api/v2/user_service";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
const SubscriptionSetting: React.FC = () => {
|
const SubscriptionSetting: React.FC = () => {
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [licenseKey, setLicenseKey] = useState<string>("");
|
const [licenseKey, setLicenseKey] = useState<string>("");
|
||||||
const isAdmin = currentUser.role === "ADMIN";
|
const isAdmin = currentUser.role === Role.ADMIN;
|
||||||
const profile = workspaceStore.profile;
|
const profile = workspaceStore.profile;
|
||||||
|
|
||||||
const handleUpdateLicenseKey = async () => {
|
const handleUpdateLicenseKey = async () => {
|
||||||
@ -37,7 +38,7 @@ const SubscriptionSetting: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-8 pb-24 flex flex-col justify-start items-start gap-y-12">
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-8 pb-24 flex flex-col justify-start items-start gap-y-12">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Subscription</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Subscription</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@ -55,7 +56,7 @@ const SubscriptionSetting: React.FC = () => {
|
|||||||
<div className="w-full flex justify-between items-center mt-4">
|
<div className="w-full flex justify-between items-center mt-4">
|
||||||
<div>
|
<div>
|
||||||
{profile.plan === PlanType.FREE && (
|
{profile.plan === PlanType.FREE && (
|
||||||
<Link href="https://yourselfhosted.lemonsqueezy.com/checkout/buy/d03a2696-8a8b-49c9-9e19-d425e3884fd7" target="_blank">
|
<Link href="https://yourselfhosted.lemonsqueezy.com/checkout/buy/947e9a56-c93a-4294-8d71-2ea4b0f3ec51" target="_blank">
|
||||||
Buy a license key
|
Buy a license key
|
||||||
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -69,11 +70,16 @@ const SubscriptionSetting: React.FC = () => {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<section className="w-full pb-8 dark:bg-zinc-900 flex items-center justify-center">
|
<section className="w-full pb-8 dark:bg-zinc-900 flex items-center justify-center">
|
||||||
<div className="w-full px-6">
|
<div className="w-full px-6">
|
||||||
<Alert className="!inline-block mb-12">
|
<div className="max-w-4xl mx-auto mb-12">
|
||||||
Slash is open source bookmarks and link sharing platform. Our source code is available and accessible on{" "}
|
<Alert className="!inline-block mb-12">
|
||||||
<Link href="https://github.com/boojack/slash">GitHub</Link> so anyone can get it, inspect it and review it.
|
Slash is open source bookmarks and link sharing platform. Our source code is available and accessible on{" "}
|
||||||
</Alert>
|
<Link href="https://github.com/yourselfhosted/slash" target="_blank">
|
||||||
<div className="w-full grid grid-cols-1 gap-12 mt-8 md:grid-cols-3">
|
GitHub
|
||||||
|
</Link>{" "}
|
||||||
|
so anyone can get it, inspect it and review it.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
<div className="w-full grid grid-cols-1 gap-6 lg:gap-12 mt-8 md:grid-cols-3 md:max-w-4xl mx-auto">
|
||||||
<div className="flex flex-col p-6 bg-white dark:bg-zinc-800 shadow-lg rounded-lg justify-between border border-gray-300 dark:border-zinc-700">
|
<div className="flex flex-col p-6 bg-white dark:bg-zinc-800 shadow-lg rounded-lg justify-between border border-gray-300 dark:border-zinc-700">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold text-center dark:text-gray-300">Free</h3>
|
<h3 className="text-2xl font-bold text-center dark:text-gray-300">Free</h3>
|
||||||
@ -137,10 +143,10 @@ const SubscriptionSetting: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
className="w-full"
|
className="w-full"
|
||||||
underline="none"
|
underline="none"
|
||||||
href="https://yourselfhosted.lemonsqueezy.com/checkout/buy/d03a2696-8a8b-49c9-9e19-d425e3884fd7"
|
href="https://yourselfhosted.lemonsqueezy.com/checkout/buy/947e9a56-c93a-4294-8d71-2ea4b0f3ec51"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button className="w-full bg-gradient-to-r from-pink-500 to-purple-500 shadow hover:opacity-80">Get Started</Button>
|
<Button className="w-full bg-gradient-to-r from-pink-500 to-purple-500 shadow hover:opacity-80">Get Pro License</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -171,7 +177,7 @@ const SubscriptionSetting: React.FC = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link className="w-full" underline="none" href="mailto:stevenlgtm@gmail.com" target="_blank">
|
<Link className="w-full" underline="none" href="mailto:yourselfhosted@gmail.com" target="_blank">
|
||||||
<Button className="w-full">Contact us</Button>
|
<Button className="w-full">Contact us</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|