69 Commits

Author SHA1 Message Date
764d776524 chore: redirect to instance url 2023-12-24 09:50:10 +08:00
7c9798b6b1 chore: upgrader to 0.5.1 2023-12-24 09:17:53 +08:00
70b0645f2e chore: update shortcut card style 2023-12-24 09:17:41 +08:00
88606e0a0c chore: update embed frontend 2023-12-24 01:05:15 +08:00
91708da5fc chore: update dev version 2023-12-24 00:32:29 +08:00
867d150a6d chore: fix member list 2023-12-24 00:26:49 +08:00
9259a85e69 chore: fix create shortcut view activity 2023-12-24 00:22:03 +08:00
546d87ca0b fix: update shortcut 2023-12-23 23:52:44 +08:00
b73f7070e4 chore(deps): bump golang.org/x/crypto from 0.14.0 to 0.17.0 (#53)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.14.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.14.0...v0.17.0)

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

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

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

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

* remove the temp part of the shortcut name
2023-12-08 09:38:57 +08:00
263812f98f chore: update package name 2023-11-25 10:40:16 +08:00
b7999a4db2 chore: update auth service 2023-11-23 22:02:19 +08:00
38e5398cb9 chore: fix error message 2023-11-23 20:46:39 +08:00
a4e91541cf chore: add cookies 2023-11-23 20:06:42 +08:00
5e227da0c4 chore: add docs for legacy api 2023-11-22 20:28:36 +08:00
59e1281960 revert: chore: remove deperecated api 2023-11-22 20:27:11 +08:00
01e49e23b5 chore: remove deperecated api 2023-11-22 20:26:26 +08:00
2f30162add chore: migrate auth service 2023-11-22 20:16:26 +08:00
3be52e7ab8 chore: migrate shortcut analytics 2023-11-22 19:47:23 +08:00
c85442d39f chore: fix update shortcut 2023-11-22 19:18:33 +08:00
61b167ef66 chore: use shortcut v2 api 2023-11-21 23:33:34 +08:00
c449669793 chore: fix update shortcut 2023-11-21 22:32:55 +08:00
0c2283a831 chore: add version to workspace profile 2023-11-21 22:12:12 +08:00
50d9873ec1 chore: tweak shortcut link display 2023-11-21 22:08:46 +08:00
0b5f54b5b2 chore: update favicon rounded 2023-11-21 21:41:21 +08:00
ec581076ef chore: update logo 2023-11-21 21:22:39 +08:00
c71575faed chore: user user store 2023-11-21 21:00:36 +08:00
35785a1a28 chore(frontend): update user module 2023-11-20 23:14:16 +08:00
832eb7cbf1 chore: use resource name input for collection 2023-11-20 21:40:43 +08:00
65504cf537 chore: update shortcut name input 2023-11-20 21:35:47 +08:00
6fa1c30fb7 chore: update collection checks 2023-11-20 20:46:54 +08:00
d18872aa5f chore: update shortcut form fields 2023-11-19 23:57:09 +08:00
5f94f3f893 chore: update extension version 2023-11-19 23:34:38 +08:00
b03c94f75d chore: update resource description in extension 2023-11-19 23:34:00 +08:00
e9905cbc39 chore: update shortcut name checks 2023-11-19 23:25:43 +08:00
fdee03cc99 chore: remove omnibox 2023-11-19 23:25:36 +08:00
cb98be1891 chore: update resource folder 2023-11-16 08:31:08 +08:00
80edd1b9a9 chore: tweak readme links 2023-11-16 08:21:43 +08:00
91ad30ae27 chore: update readme 2023-11-16 08:20:59 +08:00
168ad39076 chore: add workspace visibility to collection 2023-11-16 08:16:06 +08:00
0a62579814 chore: update shortcut frame 2023-11-16 08:05:33 +08:00
3da0e4720e chore: add collection seed data 2023-11-16 07:38:09 +08:00
dad0d91d01 chore: update collection detail page 2023-11-15 23:44:58 +08:00
92635fe395 chore: update list collections api 2023-11-15 23:31:47 +08:00
fbc089569d feat: add search box in collection dashboard 2023-11-15 22:54:28 +08:00
2296eb96ef chore: use drawer instead of dialog 2023-11-15 22:14:36 +08:00
30d9dd04bb chore: update collection docs 2023-11-12 16:54:30 +08:00
216 changed files with 12681 additions and 4858 deletions

View File

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

View File

@ -15,7 +15,6 @@ FROM golang:1.21-alpine AS backend
WORKDIR /backend-build WORKDIR /backend-build
COPY . . COPY . .
COPY --from=frontend /frontend-build/frontend/web/dist ./server/dist
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
@ -26,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

View File

@ -1,19 +1,21 @@
# 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 with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration. **Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/) 🧩 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">Discord</a> <a href="https://demo.slash.yourselfhosted.com">Live Demo</a><a href="https://discord.gg/QZqUuUAhDV">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://github.com/yourselfhosted/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/yourselfhosted/slash?logo=github"/></a>
</p> </p>
![demo](./resources/demo.png) ![demo](./docs/assets/demo.png)
## Background ## Background
@ -23,10 +25,11 @@ That's why we developed Slash, a solution that transforms these links into easil
## 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
@ -35,15 +38,15 @@ That's why we developed Slash, a solution that transforms these links into easil
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
``` ```
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md). Learn more in [Self-hosting Slash with Docker](https://github.com/yourselfhosted/slash/blob/main/docs/install.md).
## Browser Extension ## Browser Extension
Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL. Slash provides a browser extension to help you use your shortcuts in the search bar to go to the corresponding URL.
![browser-extension-example](./resources/browser-extension-example.png) ![browser-extension-example](./docs/assets/browser-extension-example.png)
Learn more in [The Browser Extension of Slash](https://github.com/boojack/slash/blob/main/docs/install-browser-extension.md). 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

1
api/v1/README.md Normal file
View File

@ -0,0 +1 @@
> The v1 API has been deprecated. Please use the v2 API instead.

View File

@ -4,14 +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/server/metric" "github.com/yourselfhosted/slash/internal/util"
"github.com/boojack/slash/store" "github.com/yourselfhosted/slash/server/metric"
"github.com/yourselfhosted/slash/store"
) )
type ReferenceInfo struct { type ReferenceInfo struct {
@ -38,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)

View File

@ -11,11 +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/metric" "github.com/yourselfhosted/slash/server/metric"
"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 SignInRequest struct { type SignInRequest struct {

View File

@ -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 (

View File

@ -1,118 +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/server/metric"
"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)
}
metric.Enqueue("shortcut redirect")
return redirectToShortcut(c, shortcut)
})
}
func redirectToShortcut(c echo.Context, shortcut *storepb.Shortcut) error {
isValidURL := isValidURLString(shortcut.Link)
if shortcut.OgMetadata == nil || (shortcut.OgMetadata.Title == "" && shortcut.OgMetadata.Description == "" && shortcut.OgMetadata.Image == "") {
if isValidURL {
return c.Redirect(http.StatusSeeOther, shortcut.Link)
}
return c.String(http.StatusOK, shortcut.Link)
}
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
metadataList := []string{
fmt.Sprintf(`<title>%s</title>`, shortcut.OgMetadata.Title),
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OgMetadata.Description),
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OgMetadata.Title),
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OgMetadata.Description),
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OgMetadata.Image),
`<meta property="og:type" content="website" />`,
// Twitter related metadata.
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OgMetadata.Title),
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OgMetadata.Description),
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OgMetadata.Image),
`<meta name="twitter:card" content="summary_large_image" />`,
}
if isValidURL {
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
}
body := ""
if isValidURL {
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
} else {
body = html.EscapeString(shortcut.Link)
}
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
return c.HTML(http.StatusOK, htmlString)
}
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *storepb.Shortcut) error {
payload := &ActivityShorcutViewPayload{
ShortcutID: shortcut.Id,
IP: c.RealIP(),
Referer: c.Request().Referer(),
UserAgent: c.Request().UserAgent(),
}
payloadStr, err := json.Marshal(payload)
if err != nil {
return errors.Wrap(err, "Failed to marshal activity payload")
}
activity := &store.Activity{
CreatorID: BotID,
Type: store.ActivityShortcutView,
Level: store.ActivityInfo,
Payload: string(payloadStr),
}
_, err = s.Store.CreateActivity(c.Request().Context(), activity)
if err != nil {
return errors.Wrap(err, "Failed to create activity")
}
return nil
}
func isValidURLString(s string) bool {
_, err := url.ParseRequestURI(s)
return err == nil
}

View File

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

View File

@ -10,10 +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/server/metric" "github.com/yourselfhosted/slash/server/metric"
"github.com/boojack/slash/store" "github.com/yourselfhosted/slash/store"
) )
// Visibility is the type of a shortcut visibility. // Visibility is the type of a shortcut visibility.
@ -102,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,
@ -179,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,
@ -315,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")

View File

@ -10,10 +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/metric" "github.com/yourselfhosted/slash/server/metric"
"github.com/boojack/slash/server/service/license" "github.com/yourselfhosted/slash/server/service/license"
"github.com/boojack/slash/store" "github.com/yourselfhosted/slash/store"
) )
const ( const (

View File

@ -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)
} }

View File

@ -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 {

View File

@ -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.

View File

@ -5,7 +5,11 @@ 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.ShortcutService/GetShortcut": 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.CollectionService/GetCollectionByName": true, "/slash.api.v2.CollectionService/GetCollectionByName": true,
} }
@ -21,6 +25,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
View 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
}

View File

@ -8,21 +8,36 @@ 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"
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/metric" "github.com/yourselfhosted/slash/server/metric"
"github.com/boojack/slash/store" "github.com/yourselfhosted/slash/server/service/license"
"github.com/yourselfhosted/slash/store"
) )
func (s *APIV2Service) ListCollections(ctx context.Context, _ *apiv2pb.ListCollectionsRequest) (*apiv2pb.ListCollectionsResponse, error) { func (s *APIV2Service) ListCollections(ctx context.Context, _ *apiv2pb.ListCollectionsRequest) (*apiv2pb.ListCollectionsResponse, error) {
userID := ctx.Value(userIDContextKey).(int32) userID := ctx.Value(userIDContextKey).(int32)
find := &store.FindCollection{} collections, err := s.Store.ListCollections(ctx, &store.FindCollection{
find.CreatorID = &userID CreatorID: &userID,
collections, err := s.Store.ListCollections(ctx, find) VisibilityList: []store.Visibility{
store.VisibilityPrivate,
},
})
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get collection list, err: %v", err) 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{} convertedCollections := []*apiv2pb.Collection{}
for _, collection := range collections { for _, collection := range collections {
convertedCollections = append(convertedCollections, convertCollectionFromStore(collection)) convertedCollections = append(convertedCollections, convertCollectionFromStore(collection))
@ -84,6 +99,22 @@ func (s *APIV2Service) GetCollectionByName(ctx context.Context, request *apiv2pb
} }
func (s *APIV2Service) CreateCollection(ctx context.Context, request *apiv2pb.CreateCollectionRequest) (*apiv2pb.CreateCollectionResponse, error) { 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) userID := ctx.Value(userIDContextKey).(int32)
collection := &storepb.Collection{ collection := &storepb.Collection{
CreatorId: userID, CreatorId: userID,

View File

@ -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
View 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
}

View File

@ -6,15 +6,20 @@ import (
"strings" "strings"
"time" "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" "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"
) )
func (s *APIV2Service) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) { func (s *APIV2Service) ListShortcuts(ctx context.Context, _ *apiv2pb.ListShortcutsRequest) (*apiv2pb.ListShortcutsResponse, error) {
@ -54,7 +59,7 @@ func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShor
ID: &request.Id, ID: &request.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 { if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found") return nil, status.Errorf(codes.NotFound, "shortcut not found")
@ -81,7 +86,48 @@ func (s *APIV2Service) GetShortcut(ctx context.Context, request *apiv2pb.GetShor
return response, nil return response, nil
} }
func (s *APIV2Service) GetShortcutByName(ctx context.Context, request *apiv2pb.GetShortcutByNameRequest) (*apiv2pb.GetShortcutByNameResponse, error) {
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
Name: &request.Name,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get shortcut by name: %v", err)
}
if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found")
}
userID, 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")
}
}
// Create shortcut view activity.
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
}
func (s *APIV2Service) 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,
@ -134,7 +180,7 @@ func (s *APIV2Service) UpdateShortcut(ctx context.Context, request *apiv2pb.Upda
ID: &request.Shortcut.Id, 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 { if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found") return nil, status.Errorf(codes.NotFound, "shortcut not found")
@ -143,24 +189,28 @@ func (s *APIV2Service) UpdateShortcut(ctx context.Context, request *apiv2pb.Upda
return nil, status.Errorf(codes.PermissionDenied, "Permission denied") return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
} }
update := &store.UpdateShortcut{} update := &store.UpdateShortcut{
ID: shortcut.Id,
}
for _, path := range request.UpdateMask.Paths { for _, path := range request.UpdateMask.Paths {
switch path { switch path {
case "name":
update.Name = &request.Shortcut.Name
case "link": case "link":
update.Link = &request.Shortcut.Link update.Link = &request.Shortcut.Link
case "title": case "title":
update.Title = &request.Shortcut.Title update.Title = &request.Shortcut.Title
case "description":
update.Description = &request.Shortcut.Description
case "tags": case "tags":
tag := strings.Join(request.Shortcut.Tags, " ") tag := strings.Join(request.Shortcut.Tags, " ")
update.Tag = &tag update.Tag = &tag
case "description":
update.Description = &request.Shortcut.Description
case "visibility": case "visibility":
visibility := store.Visibility(request.Shortcut.Visibility.String()) visibility := store.Visibility(request.Shortcut.Visibility.String())
update.Visibility = &visibility update.Visibility = &visibility
case "og_metadata": case "og_metadata":
if request.Shortcut.OgMetadata != nil { if request.Shortcut.OgMetadata != nil {
update.OpenGraphMetadata = &store.OpenGraphMetadata{ update.OpenGraphMetadata = &storepb.OpenGraphMetadata{
Title: request.Shortcut.OgMetadata.Title, Title: request.Shortcut.OgMetadata.Title,
Description: request.Shortcut.OgMetadata.Description, Description: request.Shortcut.OgMetadata.Description,
Image: request.Shortcut.OgMetadata.Image, Image: request.Shortcut.OgMetadata.Image,
@ -195,7 +245,7 @@ func (s *APIV2Service) DeleteShortcut(ctx context.Context, request *apiv2pb.Dele
ID: &request.Id, ID: &request.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 { if shortcut == nil {
return nil, status.Errorf(codes.NotFound, "shortcut not found") return nil, status.Errorf(codes.NotFound, "shortcut not found")
@ -214,6 +264,106 @@ func (s *APIV2Service) DeleteShortcut(ctx context.Context, request *apiv2pb.Dele
return response, nil return response, nil
} }
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 { func (s *APIV2Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
payload := &storepb.ActivityShorcutCreatePayload{ payload := &storepb.ActivityShorcutCreatePayload{
ShortcutId: shortcut.Id, ShortcutId: shortcut.Id,
@ -256,9 +406,9 @@ func (s *APIV2Service) convertShortcutFromStorepb(ctx context.Context, shortcut
} }
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", composedShortcut.Id)}, PayloadShortcutID: &composedShortcut.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")

View File

@ -6,7 +6,7 @@ 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"
) )
func (s *APIV2Service) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) { func (s *APIV2Service) GetSubscription(ctx context.Context, _ *apiv2pb.GetSubscriptionRequest) (*apiv2pb.GetSubscriptionResponse, error) {

View File

@ -12,11 +12,16 @@ 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"
)
const (
// BotID is the id of bot.
BotID = 0
) )
func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) { func (s *APIV2Service) ListUsers(ctx context.Context, _ *apiv2pb.ListUsersRequest) (*apiv2pb.ListUsersResponse, error) {

View File

@ -7,9 +7,9 @@ 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"
) )
func (s *APIV2Service) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) { func (s *APIV2Service) GetUserSetting(ctx context.Context, request *apiv2pb.GetUserSettingRequest) (*apiv2pb.GetUserSettingResponse, error) {

View File

@ -11,19 +11,21 @@ import (
"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.UnimplementedWorkspaceServiceServer
apiv2pb.UnimplementedSubscriptionServiceServer apiv2pb.UnimplementedSubscriptionServiceServer
apiv2pb.UnimplementedAuthServiceServer
apiv2pb.UnimplementedUserServiceServer apiv2pb.UnimplementedUserServiceServer
apiv2pb.UnimplementedUserSettingServiceServer apiv2pb.UnimplementedUserSettingServiceServer
apiv2pb.UnimplementedShortcutServiceServer apiv2pb.UnimplementedShortcutServiceServer
apiv2pb.UnimplementedCollectionServiceServer apiv2pb.UnimplementedCollectionServiceServer
apiv2pb.UnimplementedMemoServiceServer
Secret string Secret string
Profile *profile.Profile Profile *profile.Profile
@ -52,10 +54,12 @@ func NewAPIV2Service(secret string, profile *profile.Profile, store *store.Store
apiv2pb.RegisterSubscriptionServiceServer(grpcServer, apiV2Service) apiv2pb.RegisterSubscriptionServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterWorkspaceServiceServer(grpcServer, apiV2Service) apiv2pb.RegisterWorkspaceServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterAuthServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterUserServiceServer(grpcServer, apiV2Service) apiv2pb.RegisterUserServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterUserSettingServiceServer(grpcServer, apiV2Service) apiv2pb.RegisterUserSettingServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterShortcutServiceServer(grpcServer, apiV2Service) apiv2pb.RegisterShortcutServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterCollectionServiceServer(grpcServer, apiV2Service) apiv2pb.RegisterCollectionServiceServer(grpcServer, apiV2Service)
apiv2pb.RegisterMemoServiceServer(grpcServer, apiV2Service)
reflection.Register(grpcServer) reflection.Register(grpcServer)
return apiV2Service return apiV2Service
@ -85,6 +89,9 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
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
} }
@ -97,6 +104,9 @@ func (s *APIV2Service) RegisterGateway(ctx context.Context, e *echo.Echo) error
if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil { if err := apiv2pb.RegisterCollectionServiceHandler(context.Background(), gwMux, conn); err != nil {
return err 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.

View File

@ -6,15 +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/store" "github.com/yourselfhosted/slash/store"
) )
func (s *APIV2Service) GetWorkspaceProfile(ctx context.Context, _ *apiv2pb.GetWorkspaceProfileRequest) (*apiv2pb.GetWorkspaceProfileResponse, error) { func (s *APIV2Service) 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.
@ -61,6 +62,8 @@ func (s *APIV2Service) GetWorkspaceSetting(ctx context.Context, _ *apiv2pb.GetWo
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 {
@ -101,6 +104,15 @@ func (s *APIV2Service) UpdateWorkspaceSetting(ctx context.Context, request *apiv
}); 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 == "instance_url" {
if _, err := s.Store.UpsertWorkspaceSetting(ctx, &storepb.WorkspaceSetting{
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" { } 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,

View File

@ -13,12 +13,12 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
_ "modernc.org/sqlite" _ "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/metric" "github.com/yourselfhosted/slash/server/metric"
"github.com/boojack/slash/server/profile" "github.com/yourselfhosted/slash/server/profile"
"github.com/boojack/slash/store" "github.com/yourselfhosted/slash/store"
"github.com/boojack/slash/store/db" "github.com/yourselfhosted/slash/store/db"
) )
const ( const (
@ -30,20 +30,27 @@ var (
mode string mode string
port int port int
data string data string
driver string
dsn string
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()
@ -92,6 +99,8 @@ 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)")
err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")) err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
if err != nil { if err != nil {
@ -105,9 +114,18 @@ 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)
}
viper.SetDefault("mode", "demo") viper.SetDefault("mode", "demo")
viper.SetDefault("port", 8082) viper.SetDefault("port", 8082)
viper.SetDefault("driver", "sqlite")
viper.SetEnvPrefix("slash") viper.SetEnvPrefix("slash")
} }
@ -134,7 +152,7 @@ func printGreetings() {
fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port) fmt.Printf("Version %s has been started on port %d\n", serverProfile.Version, serverProfile.Port)
println("---") println("---")
println("See more in:") println("See more in:")
fmt.Printf("👉GitHub: %s\n", "https://github.com/boojack/slash") fmt.Printf("👉GitHub: %s\n", "https://github.com/yourselfhosted/slash")
println("---") println("---")
} }

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/assets/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

BIN
docs/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

View File

@ -1,6 +1,6 @@
# Slash Collections # Slash Collections
**Slash Collections** introduces a feature to help you better organize and manage related Shortcuts within the Slash Shortcuts platform. **Slash Collections** introduces a feature to help you better organize and manage related Shortcuts.
## What is a Collection? ## What is a Collection?

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 280 KiB

View File

@ -1,7 +1,7 @@
{ {
"name": "slash-extension", "name": "slash-extension",
"displayName": "Slash", "displayName": "Slash",
"version": "1.0.1", "version": "1.0.3",
"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",
@ -14,44 +14,41 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/joy": "5.0.0-beta.14", "@mui/joy": "5.0.0-beta.19",
"@plasmohq/storage": "^1.8.1", "@plasmohq/storage": "^1.9.0",
"axios": "^1.6.1", "axios": "^1.6.2",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.264.0", "lucide-react": "^0.264.0",
"plasmo": "^0.83.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.6" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
"@bufbuild/buf": "^1.27.2", "@bufbuild/buf": "^1.28.1",
"@trivago/prettier-plugin-sort-imports": "^4.2.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/chrome": "^0.0.241", "@types/chrome": "^0.0.241",
"@types/lodash-es": "^4.17.11", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.9.0", "@types/node": "^20.10.5",
"@types/react": "^18.2.37", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.15.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^8.53.0", "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.32",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"protobufjs": "^7.2.5", "protobufjs": "^7.2.5",
"tailwindcss": "^3.3.5", "tailwindcss": "^3.4.0",
"typescript": "^5.2.2" "typescript": "^5.3.3"
}, },
"manifest": { "manifest": {
"omnibox": {
"keyword": "s/"
},
"permissions": [ "permissions": [
"activeTab", "activeTab",
"storage", "storage",

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
import { Storage } from "@plasmohq/storage"; import { Storage } from "@plasmohq/storage";
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
const storage = new Storage(); const storage = new Storage();
const urlRegex = /https?:\/\/s\/(.+)/; const urlRegex = /https?:\/\/s\/(.+)/;
@ -13,33 +12,14 @@ chrome.webRequest.onBeforeRequest.addListener(
const shortcutName = getShortcutNameFromUrl(param.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({ url: shortcut.link });
} }
})(); })();
}, },
{ urls: ["*://s/*", "*://*/search*"] } { urls: ["*://s/*", "*://*/search*"] }
); );
chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
const shortcuts = (await storage.getItem<Shortcut[]>("shortcuts")) || [];
const shortcut = shortcuts.find((shortcut) => shortcut.name === text);
if (!shortcut) {
return;
}
if (disposition === "currentTab") {
chrome.tabs.update({ url: shortcut.link });
} else if (disposition === "newForegroundTab") {
chrome.tabs.create({ url: shortcut.link });
} else if (disposition === "newBackgroundTab") {
chrome.tabs.create({ url: shortcut.link, active: false });
}
});
const getShortcutNameFromUrl = (urlString: string) => { const getShortcutNameFromUrl = (urlString: string) => {
const matchResult = urlRegex.exec(urlString); const matchResult = urlRegex.exec(urlString);
if (matchResult === null) { if (matchResult === null) {

View File

@ -1,34 +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 useShortcutStore from "@/store/shortcut";
import { Visibility } from "@/types/proto/api/v2/common"; import { Visibility } from "@/types/proto/api/v2/common";
import { CreateShortcutResponse, OpenGraphMetadata } from "@/types/proto/api/v2/shortcut_service"; 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: "",
@ -54,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 || "",
})); }));
@ -94,30 +82,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);
}; };
@ -150,7 +129,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}
/> />
@ -171,4 +150,4 @@ const CreateShortcutsButton = () => {
); );
}; };
export default CreateShortcutsButton; export default CreateShortcutButton;

View File

@ -6,7 +6,7 @@ interface Props {
} }
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;

View File

@ -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");
} }

View File

@ -28,7 +28,7 @@ const ShortcutView = (props: Props) => {
<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-lg" 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,10 +44,9 @@ 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>
</> </>
)} )}

View File

@ -1,16 +1,24 @@
import { useStorage } from "@plasmohq/storage/hook";
import classNames from "classnames"; import classNames from "classnames";
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service"; 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 grid grid-cols-2 gap-2")}>
{shortcuts.map((shortcut) => {
return <ShortcutView key={shortcut.id} shortcut={shortcut} />;
})}
</div>
</div> </div>
); );
}; };

View File

@ -2,12 +2,12 @@ import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/jo
import { useStorage } from "@plasmohq/storage/hook"; import { useStorage } from "@plasmohq/storage/hook";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Toaster, toast } from "react-hot-toast"; import { Toaster, toast } from "react-hot-toast";
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
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";
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>
); );
}; };

View File

@ -1,21 +1,31 @@
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 type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
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>
); );
}; };

View File

@ -0,0 +1,55 @@
import axios from "axios";
import { create } from "zustand";
import { combine } from "zustand/middleware";
import { CreateShortcutResponse, ListShortcutsResponse, Shortcut } from "@/types/proto/api/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;

View File

@ -6,6 +6,7 @@
<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" />
<title>Slash</title> <title>Slash</title>
<!-- slash.metadata -->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -11,13 +11,12 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/joy": "5.0.0-beta.14", "@mui/joy": "5.0.0-beta.19",
"@reduxjs/toolkit": "^1.9.7", "@reduxjs/toolkit": "^1.9.7",
"axios": "^1.6.0",
"classnames": "^2.3.2", "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.6.0", "i18next": "^23.7.11",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.292.0", "lucide-react": "^0.292.0",
"nice-grpc-web": "^3.3.2", "nice-grpc-web": "^3.3.2",
@ -25,32 +24,34 @@
"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.3.1", "react-i18next": "^13.5.0",
"react-redux": "^8.1.3", "react-router-dom": "^6.21.1",
"react-router-dom": "^6.18.0", "react-use": "^17.4.2",
"react-use": "^17.4.0", "tailwindcss": "^3.4.0",
"tailwindcss": "^3.3.5", "zustand": "^4.4.7"
"zustand": "^4.4.6"
}, },
"devDependencies": { "devDependencies": {
"@bufbuild/buf": "^1.27.2", "@bufbuild/buf": "^1.28.1",
"@trivago/prettier-plugin-sort-imports": "^4.2.1", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/lodash-es": "^4.17.11", "@types/lodash-es": "^4.17.12",
"@types/react": "^18.2.37", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^6.15.0",
"@vitejs/plugin-react-swc": "^3.4.1", "@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^8.53.0", "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.32",
"prettier": "2.6.2", "prettier": "2.6.2",
"protobufjs": "^7.2.5", "protobufjs": "^7.2.5",
"typescript": "^5.2.2", "typescript": "^5.3.3",
"vite": "^4.5.0" "vite": "^5.0.10"
},
"resolutions": {
"csstype": "3.1.2"
} }
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 280 KiB

View File

@ -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);
})(); })();

View File

@ -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>

View File

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

View File

@ -1,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,13 +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.length === 0 && ( {analytics.references.length === 0 && (
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400"> <div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" /> <Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No data found.</p> <p className="ml-2">No data found.</p>
</div> </div>
)} )}
{analytics.referenceData.map((reference) => ( {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 ? (
@ -95,13 +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.length === 0 && ( {analytics.browsers.length === 0 && (
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400"> <div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" /> <Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No data found.</p> <p className="ml-2">No data found.</p>
</div> </div>
)} )}
{analytics.browserData.map((reference) => ( {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"}
@ -118,13 +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.length === 0 && ( {analytics.devices.length === 0 && (
<div className="w-full flex flex-row justify-center items-center py-6 text-gray-400"> <div className="w-full flex flex-row justify-center items-center py-6 text-gray-400">
<Icon.PackageOpen className="w-6 h-auto" /> <Icon.PackageOpen className="w-6 h-auto" />
<p className="ml-2">No data found.</p> <p className="ml-2">No data found.</p>
</div> </div>
)} )}
{analytics.deviceData.map((device) => ( {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>

View File

@ -54,7 +54,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
toast("Password changed"); toast("Password changed");
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
requestState.setFinish(); requestState.setFinish();
}; };
@ -62,7 +62,7 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">Change Password</span> <span className="text-lg font-medium">Change Password</span>
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -7,11 +7,13 @@ import { Link } from "react-router-dom";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { useAppSelector } from "@/stores";
import useCollectionStore from "@/stores/v1/collection"; 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 { Collection } from "@/types/proto/api/v2/collection_service";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { showCommonDialog } from "./Alert"; import { showCommonDialog } from "./Alert";
import CreateCollectionDialog from "./CreateCollectionDialog"; import CreateCollectionDialog from "./CreateCollectionDrawer";
import Icon from "./Icon"; import Icon from "./Icon";
import ShortcutView from "./ShortcutView"; import ShortcutView from "./ShortcutView";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
@ -25,12 +27,15 @@ const CollectionView = (props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { sm } = useResponsiveWidth(); const { sm } = useResponsiveWidth();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const userStore = useUserStore();
const currentUser = userStore.getCurrentUser();
const collectionStore = useCollectionStore(); const collectionStore = useCollectionStore();
const { shortcutList } = useAppSelector((state) => state.shortcut); const shortcutList = useShortcutStore().getShortcutList();
const [showEditDialog, setShowEditDialog] = useState<boolean>(false); const [showEditDialog, setShowEditDialog] = useState<boolean>(false);
const shortcuts = collection.shortcutIds const shortcuts = collection.shortcutIds
.map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId)) .map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId))
.filter(Boolean) as any as Shortcut[]; .filter(Boolean) as any as Shortcut[];
const showAdminActions = currentUser.id === collection.creatorId;
const handleCopyCollectionLink = () => { const handleCopyCollectionLink = () => {
copy(absolutifyLink(`/c/${collection.name}`)); copy(absolutifyLink(`/c/${collection.name}`));
@ -57,37 +62,48 @@ const CollectionView = (props: Props) => {
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}> <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="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-auto flex flex-col justify-start items-start mr-2">
<div className="w-full truncate" onClick={handleCopyCollectionLink}> <div className="w-full truncate">
<span className="leading-6 font-medium dark:text-gray-400">{collection.title}</span> <Link className="leading-6 font-medium dark:text-gray-400" to={`/c/${collection.name}`} unstable_viewTransition>
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400">(c/{collection.name})</span> {collection.title}
</Link>
<span className="ml-1 leading-6 text-gray-500 dark:text-gray-400" onClick={handleCopyCollectionLink}>
(c/{collection.name})
</span>
</div> </div>
<p className="text-sm text-gray-500">{collection.description}</p> <p className="text-sm text-gray-500">{collection.description}</p>
</div> </div>
<div className="flex flex-row justify-end items-center shrink-0"> <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}`}> <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 mr-2" /> <Icon.Share className="w-4 h-auto" />
</Link> </Link>
<Dropdown {showAdminActions && (
actionsClassName="!w-28 dark:text-gray-500" <Dropdown
actions={ trigger={
<> <button className="flex flex-row justify-center items-center rounded text-gray-400 cursor-pointer hover:text-gray-500">
<button <Icon.MoreVertical className="w-4 h-auto" />
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)}
>
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
</button> </button>
<button }
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800" actionsClassName="!w-28 text-sm"
onClick={() => { actions={
handleDeleteCollectionButtonClick(); <>
}} <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"
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")} onClick={() => setShowEditDialog(true)}
</button> >
</> <Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
} </button>
></Dropdown> <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> </div>
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3"> <div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">

View File

@ -80,14 +80,14 @@ const CreateAccessTokenDialog: React.FC<Props> = (props: Props) => {
onClose(); onClose();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
}; };
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">Create Access Token</span> <span className="text-lg font-medium">Create Access Token</span>
<Button variant="plain" onClick={onClose}> <Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -1,12 +1,13 @@
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy"; import { Button, DialogActions, DialogContent, DialogTitle, Drawer, Input, ModalClose, Radio, RadioGroup } from "@mui/joy";
import { isUndefined } from "lodash-es"; 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 { useAppSelector } from "@/stores";
import useCollectionStore from "@/stores/v1/collection"; import useCollectionStore from "@/stores/v1/collection";
import useShortcutStore from "@/stores/v1/shortcut";
import { Collection } from "@/types/proto/api/v2/collection_service"; import { Collection } from "@/types/proto/api/v2/collection_service";
import { Visibility } from "@/types/proto/api/v2/common"; import { Visibility } from "@/types/proto/api/v2/common";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { convertVisibilityFromPb } from "@/utils/visibility"; import { convertVisibilityFromPb } from "@/utils/visibility";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import Icon from "./Icon"; import Icon from "./Icon";
@ -22,21 +23,30 @@ interface State {
collectionCreate: Collection; collectionCreate: Collection;
} }
const CreateCollectionDialog: React.FC<Props> = (props: Props) => { const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
const { onClose, onConfirm, collectionId } = props; const { onClose, onConfirm, collectionId } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const collectionStore = useCollectionStore(); const collectionStore = useCollectionStore();
const { shortcutList } = useAppSelector((state) => state.shortcut); const shortcutList = useShortcutStore().getShortcutList();
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
collectionCreate: Collection.fromPartial({ collectionCreate: Collection.fromPartial({
visibility: Visibility.PRIVATE, visibility: Visibility.PRIVATE,
}), }),
}); });
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]); const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
const requestState = useLoading(false);
const isCreating = isUndefined(collectionId); const isCreating = isUndefined(collectionId);
const loadingState = useLoading(!isCreating);
const requestState = useLoading(false);
const unselectedShortcuts = shortcutList const unselectedShortcuts = shortcutList
.filter((shortcut) => (state.collectionCreate.visibility === Visibility.PUBLIC ? shortcut.visibility === "PUBLIC" : true)) .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)); .filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
useEffect(() => { useEffect(() => {
@ -55,11 +65,16 @@ const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
.map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId)) .map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId))
.filter(Boolean) as Shortcut[] .filter(Boolean) as Shortcut[]
); );
loadingState.setFinish();
} }
} }
})(); })();
}, [collectionId]); }, [collectionId]);
if (loadingState.isLoading) {
return null;
}
const setPartialState = (partialState: Partial<State>) => { const setPartialState = (partialState: Partial<State>) => {
setState({ setState({
...state, ...state,
@ -141,28 +156,22 @@ const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
}; };
return ( return (
<Modal open={true}> <Drawer anchor="right" open={true} onClose={onClose}>
<ModalDialog> <DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
<div className="w-full flex flex-row justify-between items-center"> <ModalClose />
<span className="text-lg font-medium">{isCreating ? "Create Collection" : "Edit Collection"}</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 w-80 sm:w-96 max-w-full">
<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"> <span className="mb-2">
Name <span className="text-red-600">*</span> Name <span className="text-red-600">*</span>
</span> </span>
<div className="relative w-full"> <Input
<Input className="w-full"
className="w-full" type="text"
type="text" placeholder="The memorable name of the collection"
placeholder="Should be an unique name and will be put in url" value={state.collectionCreate.name}
value={state.collectionCreate.name} onChange={handleNameInputChange}
onChange={handleNameInputChange} />
/>
</div>
</div> </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"> <span className="mb-2">
@ -195,6 +204,7 @@ const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
<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.collectionCreate.visibility} onChange={handleVisibilityInputChange}> <RadioGroup orientation="horizontal" value={state.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} /> <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`)} /> <Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
</RadioGroup> </RadioGroup>
</div> </div>
@ -206,7 +216,7 @@ const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
<p className="mb-2"> <p className="mb-2">
<span>Shortcuts</span> <span>Shortcuts</span>
<span className="opacity-60">({selectedShortcuts.length})</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>} {selectedShortcuts.length === 0 && <span className="ml-2 italic opacity-80 text-sm">(Select a shortcut first)</span>}
</p> </p>
<div className="w-full py-1 px-px flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2"> <div className="w-full py-1 px-px flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2">
{selectedShortcuts.map((shortcut) => { {selectedShortcuts.map((shortcut) => {
@ -241,19 +251,20 @@ const CreateCollectionDialog: React.FC<Props> = (props: Props) => {
)} )}
</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 CreateCollectionDialog; export default CreateCollectionDrawer;

View File

@ -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.PRIVATE,
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,
}, },
}), }),
@ -169,21 +182,19 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
} }
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,49 +205,63 @@ 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"> <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"> <span className="mb-2">
Name <span className="text-red-600">*</span> Name <span className="text-red-600">*</span>
</span> </span>
<div className="relative w-full"> <Input
<Input className="w-full"
className="w-full" type="text"
type="text" placeholder="The memorable name of the shortcut"
placeholder="Should be an unique name and will be put in url" value={state.shortcutCreate.name}
value={state.shortcutCreate.name} onChange={handleNameInputChange}
onChange={handleNameInputChange} />
/>
</div>
</div> </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"> <span className="mb-2">
Destination URL <span className="text-red-600">*</span> Link <span className="text-red-600">*</span>
</span> </span>
<Input <Input
className="w-full" className="w-full"
type="text" type="text"
placeholder="https://github.com/boojack/slash" 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-500" /> <Icon.Asterisk className="w-4 h-auto shrink-0 mx-1 text-gray-400 dark:text-gray-500" />
@ -258,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">More</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",
@ -333,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>
@ -344,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>
@ -355,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;

View File

@ -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>

View File

@ -13,7 +13,7 @@ const DemoBanner: React.FC = () => {
<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

View File

@ -50,7 +50,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
toast("User information updated"); toast("User information updated");
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.details);
} }
requestState.setFinish(); requestState.setFinish();
}; };
@ -58,7 +58,7 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
return ( return (
<Modal open={true}> <Modal open={true}>
<ModalDialog> <ModalDialog>
<div className="flex flex-row justify-between items-center w-80 mb-4"> <div className="flex flex-row justify-between items-center w-80">
<span className="text-lg font-medium">Edit Userinfo</span> <span className="text-lg font-medium">Edit Userinfo</span>
<Button variant="plain" onClick={handleCloseBtnClick}> <Button variant="plain" onClick={handleCloseBtnClick}>
<Icon.X className="w-5 h-auto text-gray-600" /> <Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -1,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>
)} )}

View File

@ -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" />

View File

@ -2,9 +2,10 @@ 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, useLocation } 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";
@ -17,11 +18,12 @@ const Header: React.FC = () => {
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"; 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";
}; };
@ -30,7 +32,7 @@ const Header: React.FC = () => {
<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 bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
<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="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="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-7 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" 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>
@ -41,28 +43,30 @@ const Header: React.FC = () => {
)} )}
{shouldShowRouterSwitch && ( {shouldShowRouterSwitch && (
<> <>
<span className="font-mono opacity-60 mx-1">/</span> <span className="font-mono opacity-60 mx-1 dark:text-gray-400">/</span>
<Dropdown <Dropdown
trigger={ trigger={
<button className="flex flex-row justify-end items-center cursor-pointer"> <button className="flex flex-row justify-end items-center cursor-pointer">
<span className="dark:text-gray-400">{location.pathname === "/" ? "Shortcuts" : "Collections"}</span> <span className="dark:text-gray-400">{selectedSection}</span>
<Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" /> <Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" />
</button> </button>
} }
actionsClassName="!w-36 -left-4" actionsClassName="!w-32 -left-4 text-sm"
actions={ actions={
<> <>
<Link <Link
to="/"
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="/"
unstable_viewTransition
> >
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts <Icon.SquareSlash className="w-4 h-auto mr-2 opacity-70" /> Shortcuts
</Link> </Link>
<Link <Link
to="/collections"
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="/collections"
unstable_viewTransition
> >
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" /> Collections <Icon.LibrarySquare className="w-4 h-auto mr-2 opacity-70" /> Collections
</Link> </Link>
</> </>
} }
@ -79,34 +83,36 @@ const Header: React.FC = () => {
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600 dark:text-gray-400" /> <Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600 dark:text-gray-400" />
</button> </button>
} }
actionsClassName="!w-32" actionsClassName="!w-32 text-sm"
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-4 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-4 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-4 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-4 h-auto mr-2 opacity-70" /> {t("auth.sign-out")}
</button> </button>
</> </>
} }

View File

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

View File

@ -1,10 +1,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)}
/> />
)} )}

View File

@ -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";
@ -17,113 +21,135 @@ interface Props {
const ShortcutCard = (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-lg" 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 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 flex mt-2 gap-2 overflow-x-auto">
<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 dark:text-gray-400 text-sm dark:border-zinc-700"
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
>
<VisibilityIcon className="w-4 h-auto mr-1 opacity-60" 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 dark:text-gray-400 text-sm dark:border-zinc-700"
>
<Icon.BarChart2 className="w-4 h-auto mr-1 opacity-80" />
{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>
); );
}; };

View 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;

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { getFaviconWithGoogleS2 } from "../helpers/utils"; import { getFaviconWithGoogleS2 } from "../helpers/utils";
import Icon from "./Icon"; import Icon from "./Icon";
import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
@ -26,7 +27,7 @@ const ShortcutView = (props: Props) => {
> >
<div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}> <div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
{favicon ? ( {favicon ? (
<img className="w-full h-auto rounded-lg" 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" />
)} )}
@ -35,11 +36,10 @@ const ShortcutView = (props: Props) => {
{shortcut.title ? ( {shortcut.title ? (
<> <>
<span className="dark:text-gray-400">{shortcut.title}</span> <span className="dark:text-gray-400">{shortcut.title}</span>
<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="dark:text-gray-400">{shortcut.name}</span> <span className="dark:text-gray-400">{shortcut.name}</span>
</> </>
)} )}

View File

@ -1,5 +1,6 @@
import classNames from "classnames"; import classNames from "classnames";
import { absolutifyLink } from "@/helpers/utils"; 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";
@ -10,12 +11,13 @@ 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) => { const handleShortcutClick = (shortcut: Shortcut) => {
window.open(absolutifyLink(`/s/${shortcut.name}`)); navigateTo(`/shortcut/${shortcut.id}`);
}; };
return ( return (

View File

@ -1,13 +1,13 @@
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAppSelector } from "../stores"; import useShortcutStore from "@/stores/v1/shortcut";
import useViewStore from "../stores/v1/view"; import useViewStore from "../stores/v1/view";
import Icon from "./Icon"; import Icon from "./Icon";
const ShortcutsNavigator = () => { const ShortcutsNavigator = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const viewStore = useViewStore(); const viewStore = useViewStore();
const { shortcutList } = useAppSelector((state) => state.shortcut); const shortcutList = useShortcutStore().getShortcutList();
const tags = shortcutList.map((shortcut) => shortcut.tags).flat(); const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
const currentTab = viewStore.filter.tab || `tab:all`; const currentTab = viewStore.filter.tab || `tab:all`;
const sortedTagMap = sortTags(tags); const sortedTagMap = sortTags(tags);
@ -18,7 +18,7 @@ const ShortcutsNavigator = () => {
className={classNames( className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md", "flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:all" currentTab === "tab:all"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow" ? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700" : "hover:bg-gray-200 dark:hover:bg-zinc-700"
)} )}
onClick={() => viewStore.setFilter({ tab: "tab:all" })} onClick={() => viewStore.setFilter({ tab: "tab:all" })}
@ -30,7 +30,7 @@ const ShortcutsNavigator = () => {
className={classNames( className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md", "flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:mine" currentTab === "tab:mine"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow" ? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700" : "hover:bg-gray-200 dark:hover:bg-zinc-700"
)} )}
onClick={() => viewStore.setFilter({ tab: "tab:mine" })} onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
@ -44,7 +44,7 @@ const ShortcutsNavigator = () => {
className={classNames( className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md", "flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === `tag:${tag}` currentTab === `tag:${tag}`
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow" ? "bg-blue-700 dark:bg-blue-800 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700" : "hover:bg-gray-200 dark:hover:bg-zinc-700"
)} )}
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })} onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}

View File

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

View File

@ -18,7 +18,7 @@ const ViewSetting = () => {
<Icon.Settings2 className="w-4 h-auto text-gray-500" /> <Icon.Settings2 className="w-4 h-auto text-gray-500" />
</button> </button>
} }
actionsClassName="!mt-3 !-right-2" actionsClassName="!mt-3 !right-[unset] -left-24 -ml-2"
actions={ actions={
<div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}> <div className="w-52 p-2 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">

View File

@ -1,3 +1,4 @@
import { Visibility } from "@/types/proto/api/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;

View File

@ -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 (
<> <>

View File

@ -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"

View File

@ -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,6 +69,15 @@ 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

View File

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

View File

@ -1,4 +1,5 @@
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 { CollectionServiceDefinition } from "./types/proto/api/v2/collection_service";
import { ShortcutServiceDefinition } from "./types/proto/api/v2/shortcut_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";
@ -17,9 +18,11 @@ 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);

View File

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

View File

@ -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;
};

View File

@ -51,15 +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 />
<Navigator /> <Outlet />
<Outlet /> </div>
</div> )
)}
</>
); );
}; };

View File

@ -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>
); );

View File

@ -1,59 +1,65 @@
import { Button } 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 CollectionView from "@/components/CollectionView"; import CollectionView from "@/components/CollectionView";
import CreateCollectionDialog from "@/components/CreateCollectionDialog"; import CreateCollectionDrawer from "@/components/CreateCollectionDrawer";
import { shortcutService } from "@/services";
import useCollectionStore from "@/stores/v1/collection"; import useCollectionStore from "@/stores/v1/collection";
import useShortcutStore from "@/stores/v1/shortcut";
import FilterView from "../components/FilterView"; import FilterView from "../components/FilterView";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
interface State { interface State {
showCreateCollectionDialog: boolean; showCreateCollectionDrawer: boolean;
} }
const CollectionDashboard: React.FC = () => { const CollectionDashboard: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const loadingState = useLoading(); const loadingState = useLoading();
const shortcutStore = useShortcutStore();
const collectionStore = useCollectionStore(); const collectionStore = useCollectionStore();
const collections = collectionStore.getCollectionList();
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
showCreateCollectionDialog: false, 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(() => { useEffect(() => {
Promise.all([shortcutService.getMyAllShortcuts(), collectionStore.fetchCollectionList()]).finally(() => { Promise.all([shortcutStore.fetchShortcutList(), collectionStore.fetchCollectionList()]).finally(() => {
loadingState.setFinish(); loadingState.setFinish();
}); });
}, []); }, []);
const setShowCreateCollectionDialog = (show: boolean) => { const setShowCreateCollectionDrawer = (show: boolean) => {
setState({ setState({
...state, ...state,
showCreateCollectionDialog: show, showCreateCollectionDrawer: show,
}); });
}; };
return ( 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="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-start items-start mb-4">
<div className="bg-yellow-100 dark:bg-yellow-500 dark:opacity-70 py-2 px-3 rounded-full border dark:border-yellow-600 flex flex-row justify-start items-center cursor-pointer shadow">
<Icon.LibrarySquare className="w-5 h-auto opacity-60" />
<a
className="hover:underline hover:text-blue-600"
href="https://github.com/boojack/slash/blob/main/docs/getting-started/collections.md"
target="_blank"
>
<span className="mx-1 text-sm">Collection is in Beta. Learn more in docs</span>
<Icon.ExternalLink className="w-4 h-auto inline-block" />
</a>
</div>
</div>
<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>
<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"> <div className="flex flex-row justify-start items-center">
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateCollectionDialog(true)}> <Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateCollectionDrawer(true)}>
<Icon.Plus className="w-5 h-auto" /> <Icon.Plus className="w-5 h-auto" />
<span className="ml-0.5">{t("common.create")}</span> <span className="ml-0.5">{t("common.create")}</span>
</Button> </Button>
@ -65,24 +71,24 @@ const CollectionDashboard: React.FC = () => {
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" /> <Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
{t("common.loading")} {t("common.loading")}
</div> </div>
) : collections.length === 0 ? ( ) : filteredCollections.length === 0 ? (
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400"> <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" /> <Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
<p className="mt-4">No collections found.</p> <p className="mt-4">No collections found.</p>
</div> </div>
) : ( ) : (
<div className="w-full flex flex-col justify-start items-start gap-3"> <div className="w-full flex flex-col justify-start items-start gap-3">
{collections.map((collection) => { {filteredCollections.map((collection) => {
return <CollectionView key={collection.id} collection={collection} />; return <CollectionView key={collection.id} collection={collection} />;
})} })}
</div> </div>
)} )}
</div> </div>
{state.showCreateCollectionDialog && ( {state.showCreateCollectionDrawer && (
<CreateCollectionDialog <CreateCollectionDrawer
onClose={() => setShowCreateCollectionDialog(false)} onClose={() => setShowCreateCollectionDrawer(false)}
onConfirm={() => setShowCreateCollectionDialog(false)} onConfirm={() => setShowCreateCollectionDrawer(false)}
/> />
)} )}
</> </>

View File

@ -2,8 +2,9 @@ import { Divider } from "@mui/joy";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Link, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import Icon from "@/components/Icon"; import Icon from "@/components/Icon";
import ShortcutFrame from "@/components/ShortcutFrame";
import ShortcutView from "@/components/ShortcutView"; import ShortcutView from "@/components/ShortcutView";
import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import useCollectionStore from "@/stores/v1/collection"; import useCollectionStore from "@/stores/v1/collection";
@ -11,10 +12,10 @@ import useShortcutStore from "@/stores/v1/shortcut";
import useUserStore from "@/stores/v1/user"; import useUserStore from "@/stores/v1/user";
import { Collection } from "@/types/proto/api/v2/collection_service"; import { Collection } from "@/types/proto/api/v2/collection_service";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service"; import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
import { convertShortcutFromPb } from "@/utils/shortcut";
const CollectionSpace = () => { const CollectionSpace = () => {
const { collectionName } = useParams(); const params = useParams();
const collectionName = params["*"];
const { sm } = useResponsiveWidth(); const { sm } = useResponsiveWidth();
const userStore = useUserStore(); const userStore = useUserStore();
const collectionStore = useCollectionStore(); const collectionStore = useCollectionStore();
@ -31,7 +32,6 @@ const CollectionSpace = () => {
(async () => { (async () => {
try { try {
const collection = await collectionStore.fetchCollectionByName(collectionName); const collection = await collectionStore.fetchCollectionByName(collectionName);
await userStore.getOrFetchUserById(collection.creatorId);
setCollection(collection); setCollection(collection);
setShortcuts([]); setShortcuts([]);
for (const shortcutId of collection.shortcutIds) { for (const shortcutId of collection.shortcutIds) {
@ -41,7 +41,7 @@ const CollectionSpace = () => {
return [...shortcuts, shortcut]; return [...shortcuts, shortcut];
}); });
} catch (error) { } catch (error) {
// do nth // Do nothing.
} }
} }
document.title = `${collection.title} - Slash`; document.title = `${collection.title} - Slash`;
@ -70,26 +70,26 @@ const CollectionSpace = () => {
<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 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 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 sm:w-56 sm:pr-4 flex flex-col justify-start items-start overflow-auto shrink-0">
<div className="w-full sticky top-0 bg-gray-50 dark:bg-zinc-900"> <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"> <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-2 opacity-70" /> <Icon.LibrarySquare className="w-5 h-auto mr-1 opacity-70 shrink-0" />
<span className="text-lg">{collection.title}</span> <span className="text-lg truncate">{collection.title}</span>
</div> </div>
<p className="text-gray-500 text-sm">{collection.description}</p> <p className="text-gray-500 text-sm truncate">{collection.description}</p>
<Divider className="!my-2" />
</div> </div>
<Divider className="!my-2" />
<div className="w-full flex flex-col justify-start items-start gap-2 sm:gap-1 px-px"> <div className="w-full flex flex-col justify-start items-start gap-2 sm:gap-1 px-px">
{shortcuts.map((shortcut) => { {shortcuts.map((shortcut) => {
return ( return (
<ShortcutView <ShortcutView
className={classNames( className={classNames(
"w-full py-2 cursor-pointer", "w-full py-2 cursor-pointer sm:!px-2",
selectedShortcut?.id === shortcut.id selectedShortcut?.id === shortcut.id
? "bg-gray-100 dark:bg-zinc-800" ? "bg-gray-100 dark:bg-zinc-800"
: "sm:border-transparent dark:sm:border-transparent" : "sm:border-transparent dark:sm:border-transparent"
)} )}
key={shortcut.name} key={shortcut.name}
shortcut={convertShortcutFromPb(shortcut)} shortcut={shortcut}
alwaysShowLink={!sm} alwaysShowLink={!sm}
onClick={() => handleShortcutClick(shortcut)} onClick={() => handleShortcutClick(shortcut)}
/> />
@ -100,30 +100,15 @@ const CollectionSpace = () => {
{sm && ( {sm && (
<div className="w-full h-full overflow-clip rounded-lg border dark:border-zinc-800 bg-white dark:bg-zinc-800"> <div className="w-full h-full overflow-clip rounded-lg border dark:border-zinc-800 bg-white dark:bg-zinc-800">
{selectedShortcut ? ( {selectedShortcut ? (
<div className="w-full h-full flex flex-col justify-center items-center p-8"> <ShortcutFrame key={selectedShortcut.id} shortcut={selectedShortcut} />
<Link
className="w-72 max-w-full border dark:border-zinc-900 dark:bg-zinc-900 p-6 rounded-2xl shadow-xl dark:text-gray-400 hover:opacity-80"
to={`/s/${selectedShortcut.name}`}
target="_blank"
>
<Icon.Globe2Icon className="w-12 h-auto mb-1" strokeWidth={1} />
<p className="text-lg font-medium leading-8">{selectedShortcut.title || selectedShortcut.name}</p>
<p className="text-gray-500">{selectedShortcut.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>
) : ( ) : (
<div className="w-full h-full flex flex-col justify-center items-center p-8"> <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 rounded-2xl shadow-xl"> <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" strokeWidth={1} /> <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> <p className="text-lg font-medium">Click on a tab in the Sidebar to get started.</p>
<Divider className="!my-2" /> <Divider className="!my-2" />
<p className="text-gray-400 dark:text-gray-600 text-sm mt-2"> <p className="text-gray-400 dark:text-gray-600 text-sm mt-2 italic">
Shared by <span className="italic font-medium">{creator.nickname}</span> Shared by <span className="font-medium not-italic">{creator.nickname}</span>
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,45 +1,45 @@
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 ShortcutsContainer from "../components/ShortcutsContainer"; import ShortcutsContainer from "../components/ShortcutsContainer";
import ShortcutsNavigator from "../components/ShortcutsNavigator"; 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,
}); });
}; };
@ -49,14 +49,8 @@ const Home: React.FC = () => {
<ShortcutsNavigator /> <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)} />
)} )}
</> </>
); );

View 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;

View File

@ -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("/")}
/>
)}
</>
); );
}; };

View File

@ -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,
}); });
@ -57,7 +78,7 @@ const ShortcutDetail = () => {
return ( 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="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-lg" src={favicon} decoding="async" loading="lazy" /> <img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
) : ( ) : (
@ -116,7 +137,7 @@ const ShortcutDetail = () => {
onClick={() => { onClick={() => {
setState({ setState({
...state, ...state,
showEditModal: true, showEditDrawer: true,
}); });
}} }}
> >
@ -135,8 +156,8 @@ 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 leading-4 dark:text-gray-500"> <span key={tag} className="max-w-[8rem] truncate text-gray-400 text leading-4 dark:text-gray-500">
@ -150,19 +171,24 @@ const ShortcutDetail = () => {
<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,
}) })
} }
/> />

View File

@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useParams } from "react-router-dom";
import { isURL } from "@/helpers/utils";
import useShortcutStore from "@/stores/v1/shortcut";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
const ShortcutSpace = () => {
const params = useParams();
const shortcutName = params["*"] || "";
const shortcutStore = useShortcutStore();
const [shortcut, setShortcut] = useState<Shortcut>();
useEffect(() => {
(async () => {
try {
const shortcut = await shortcutStore.fetchShortcutByName(shortcutName);
setShortcut(shortcut);
} catch (error: any) {
console.error(error);
toast.error(error.details);
}
})();
}, [shortcutName]);
if (!shortcut) {
return null;
}
if (isURL(shortcut.link)) {
window.document.title = "Redirecting...";
window.location.href = shortcut.link;
return null;
}
return <div>{shortcut.link}</div>;
};
export default ShortcutSpace;

View File

@ -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();
}; };
@ -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>

View File

@ -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();
}; };
@ -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>

View File

@ -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 () => {
@ -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>

View File

@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import Icon from "@/components/Icon"; import Icon from "@/components/Icon";
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 { Role } from "@/types/proto/api/v2/user_service";
import MemberSection from "../components/setting/MemberSection"; import MemberSection from "../components/setting/MemberSection";
import WorkspaceSection from "../components/setting/WorkspaceSection"; import WorkspaceSection from "../components/setting/WorkspaceSection";
import useUserStore from "../stores/v1/user"; import useUserStore from "../stores/v1/user";
@ -11,7 +12,7 @@ import useUserStore from "../stores/v1/user";
const WorkspaceSetting: React.FC = () => { const WorkspaceSetting: React.FC = () => {
const workspaceStore = useWorkspaceStore(); const workspaceStore = useWorkspaceStore();
const currentUser = useUserStore().getCurrentUser(); const currentUser = useUserStore().getCurrentUser();
const isAdmin = currentUser.role === "ADMIN"; const isAdmin = currentUser.role === Role.ADMIN;
const profile = workspaceStore.profile; const profile = workspaceStore.profile;
useEffect(() => { useEffect(() => {
@ -34,7 +35,7 @@ const WorkspaceSetting: React.FC = () => {
<div className="mt-2"> <div className="mt-2">
<span className="text-gray-500 mr-2">Current plan:</span> <span className="text-gray-500 mr-2">Current plan:</span>
<span className="text-2xl mr-4 dark:text-gray-400">{stringifyPlanType(profile.plan)}</span> <span className="text-2xl mr-4 dark:text-gray-400">{stringifyPlanType(profile.plan)}</span>
<Link to="/setting/subscription"> <Link to="/setting/subscription" unstable_viewTransition>
<Button size="sm" variant="outlined" startDecorator={<Icon.Settings className="w-4 h-auto" />}> <Button size="sm" variant="outlined" startDecorator={<Icon.Settings className="w-4 h-auto" />}>
Manage Manage
</Button> </Button>

View File

@ -2,6 +2,7 @@ import { createBrowserRouter } from "react-router-dom";
import CollectionDashboard from "@/pages/CollectionDashboard"; import CollectionDashboard from "@/pages/CollectionDashboard";
import CollectionSpace from "@/pages/CollectionSpace"; import CollectionSpace from "@/pages/CollectionSpace";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
import ShortcutSpace from "@/pages/ShortcutSpace";
import SignIn from "@/pages/SignIn"; import SignIn from "@/pages/SignIn";
import SignUp from "@/pages/SignUp"; import SignUp from "@/pages/SignUp";
import SubscriptionSetting from "@/pages/SubscriptionSetting"; import SubscriptionSetting from "@/pages/SubscriptionSetting";
@ -11,7 +12,6 @@ import App from "../App";
import Root from "../layouts/Root"; import Root from "../layouts/Root";
import Home from "../pages/Home"; import Home from "../pages/Home";
import ShortcutDetail from "../pages/ShortcutDetail"; import ShortcutDetail from "../pages/ShortcutDetail";
import { shortcutService } from "../services";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -41,10 +41,6 @@ const router = createBrowserRouter([
{ {
path: "/shortcut/:shortcutId", path: "/shortcut/:shortcutId",
element: <ShortcutDetail />, element: <ShortcutDetail />,
loader: async ({ params }) => {
const shortcut = await shortcutService.getOrFetchShortcutById(Number(params.shortcutId));
return shortcut;
},
}, },
{ {
path: "/setting/general", path: "/setting/general",
@ -61,7 +57,11 @@ const router = createBrowserRouter([
], ],
}, },
{ {
path: "c/:collectionName", path: "s/*",
element: <ShortcutSpace />,
},
{
path: "c/*",
element: <CollectionSpace />, element: <CollectionSpace />,
}, },
{ {

View File

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

View File

@ -1,64 +0,0 @@
import * as api from "../helpers/api";
import store from "../stores";
import { createShortcut, deleteShortcut, patchShortcut, setShortcuts } from "../stores/modules/shortcut";
const convertResponseModelShortcut = (shortcut: Shortcut): Shortcut => {
return {
...shortcut,
createdTs: shortcut.createdTs * 1000,
updatedTs: shortcut.updatedTs * 1000,
};
};
const shortcutService = {
getState: () => {
return store.getState().shortcut;
},
getMyAllShortcuts: async () => {
const data = (await api.getShortcutList()).data;
const shortcuts = data.map((s) => convertResponseModelShortcut(s));
store.dispatch(setShortcuts(shortcuts));
},
getShortcutById: (id: ShortcutId) => {
for (const shortcut of shortcutService.getState().shortcutList) {
if (shortcut.id === id) {
return shortcut;
}
}
return null;
},
getOrFetchShortcutById: async (id: ShortcutId) => {
for (const shortcut of shortcutService.getState().shortcutList) {
if (shortcut.id === id) {
return shortcut;
}
}
const data = (await api.getShortcutById(id)).data;
const shortcut = convertResponseModelShortcut(data);
store.dispatch(createShortcut(shortcut));
return shortcut;
},
createShortcut: async (shortcutCreate: ShortcutCreate) => {
const data = (await api.createShortcut(shortcutCreate)).data;
const shortcut = convertResponseModelShortcut(data);
store.dispatch(createShortcut(shortcut));
},
patchShortcut: async (shortcutPatch: ShortcutPatch) => {
const data = (await api.patchShortcut(shortcutPatch)).data;
const shortcut = convertResponseModelShortcut(data);
store.dispatch(patchShortcut(shortcut));
},
deleteShortcutById: async (shortcutId: ShortcutId) => {
await api.deleteShortcutById(shortcutId);
store.dispatch(deleteShortcut(shortcutId));
},
};
export default shortcutService;

View File

@ -1,15 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useSelector } from "react-redux";
import shortcutReducer from "./modules/shortcut";
const store = configureStore({
reducer: {
shortcut: shortcutReducer,
},
});
type AppState = ReturnType<typeof store.getState>;
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
export default store;

View File

@ -1,51 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
shortcutList: Shortcut[];
}
const shortcutSlice = createSlice({
name: "shortcut",
initialState: {
shortcutList: [],
} as State,
reducers: {
setShortcuts: (state, action: PayloadAction<Shortcut[]>) => {
return {
...state,
shortcutList: action.payload,
};
},
createShortcut: (state, action: PayloadAction<Shortcut>) => {
return {
...state,
shortcutList: state.shortcutList.concat(action.payload).sort((a, b) => b.createdTs - a.createdTs),
};
},
patchShortcut: (state, action: PayloadAction<Partial<Shortcut>>) => {
return {
...state,
shortcutList: state.shortcutList.map((s) => {
if (s.id === action.payload.id) {
return {
...s,
...action.payload,
};
} else {
return s;
}
}),
};
},
deleteShortcut: (state, action: PayloadAction<ShortcutId>) => {
return {
...state,
shortcutList: [...state.shortcutList].filter((shortcut) => shortcut.id !== action.payload),
};
},
},
});
export const { setShortcuts, createShortcut, patchShortcut, deleteShortcut } = shortcutSlice.actions;
export default shortcutSlice.reducer;

View File

@ -84,8 +84,6 @@ const useCollectionStore = create<CollectionState>()((set, get) => ({
throw new Error("Collection not found"); throw new Error("Collection not found");
} }
console.log("updatedCollection", updatedCollection);
const collectionMap = get().collectionMapById; const collectionMap = get().collectionMapById;
collectionMap[updatedCollection.id] = updatedCollection; collectionMap[updatedCollection.id] = updatedCollection;
set(collectionMap); set(collectionMap);

View File

@ -1,50 +1,128 @@
import { isEqual } from "lodash-es";
import { create } from "zustand"; import { create } from "zustand";
import { combine } from "zustand/middleware";
import { shortcutServiceClient } from "@/grpcweb"; import { shortcutServiceClient } from "@/grpcweb";
import { Shortcut } from "@/types/proto/api/v2/shortcut_service"; import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
interface ShortcutState { interface State {
shortcutMapById: Record<ShortcutId, Shortcut>; shortcutMapById: Record<number, Shortcut>;
fetchShortcutList: () => Promise<Shortcut[]>;
getOrFetchShortcutById: (id: ShortcutId) => Promise<Shortcut>;
getShortcutById: (id: ShortcutId) => Shortcut;
getShortcutList: () => Shortcut[];
} }
const useShortcutStore = create<ShortcutState>()((set, get) => ({ const getDefaultState = (): State => {
shortcutMapById: {}, return {
fetchShortcutList: async () => { shortcutMapById: {},
const { shortcuts } = await shortcutServiceClient.listShortcuts({}); };
const shortcutMap = get().shortcutMapById; };
shortcuts.forEach((shortcut) => {
shortcutMap[shortcut.id] = shortcut;
});
set(shortcutMap);
return shortcuts;
},
getOrFetchShortcutById: async (id: ShortcutId) => {
const shortcutMap = get().shortcutMapById;
if (shortcutMap[id]) {
return shortcutMap[id] as Shortcut;
}
const { shortcut } = await shortcutServiceClient.getShortcut({ const useShortcutStore = create(
id: id, combine(getDefaultState(), (set, get) => ({
}); fetchShortcutList: async () => {
if (!shortcut) { const { shortcuts } = await shortcutServiceClient.listShortcuts({});
throw new Error(`Shortcut with id ${id} not found`); const shortcutMap = get().shortcutMapById;
} shortcuts.forEach((shortcut) => {
shortcutMap[shortcut.id] = shortcut;
});
set({ shortcutMapById: shortcutMap });
return shortcuts;
},
fetchShortcutByName: async (name: string) => {
const { shortcut } = await shortcutServiceClient.getShortcutByName({
name,
});
if (!shortcut) {
throw new Error(`Shortcut with name ${name} not found`);
}
return shortcut;
},
getOrFetchShortcutById: async (id: number) => {
const shortcutMap = get().shortcutMapById;
if (shortcutMap[id]) {
return shortcutMap[id] as Shortcut;
}
shortcutMap[id] = shortcut; const { shortcut } = await shortcutServiceClient.getShortcut({
set(shortcutMap); id,
return shortcut; });
}, if (!shortcut) {
getShortcutById: (id: ShortcutId) => { throw new Error(`Shortcut with id ${id} not found`);
const shortcutMap = get().shortcutMapById; }
return shortcutMap[id] as Shortcut;
}, shortcutMap[id] = shortcut;
getShortcutList: () => { set({ shortcutMapById: shortcutMap });
return Object.values(get().shortcutMapById); return shortcut;
}, },
})); getShortcutById: (id: number) => {
const shortcutMap = get().shortcutMapById;
return shortcutMap[id] || unknownShortcut;
},
getShortcutList: () => {
return Object.values(get().shortcutMapById);
},
createShortcut: async (shortcut: Shortcut) => {
const { shortcut: createdShortcut } = await shortcutServiceClient.createShortcut({
shortcut: shortcut,
});
if (!createdShortcut) {
throw new Error(`Failed to create shortcut`);
}
const shortcutMap = get().shortcutMapById;
shortcutMap[createdShortcut.id] = createdShortcut;
set({ shortcutMapById: shortcutMap });
return createdShortcut;
},
updateShortcut: async (shortcut: Partial<Shortcut>, updateMask: string[]) => {
const { shortcut: updatedShortcut } = await shortcutServiceClient.updateShortcut({
shortcut: shortcut,
updateMask,
});
if (!updatedShortcut) {
throw new Error(`Failed to update shortcut`);
}
const shortcutMap = get().shortcutMapById;
shortcutMap[updatedShortcut.id] = updatedShortcut;
set({ shortcutMapById: shortcutMap });
return updatedShortcut;
},
deleteShortcut: async (id: number) => {
await shortcutServiceClient.deleteShortcut({
id,
});
const shortcutMap = get().shortcutMapById;
delete shortcutMap[id];
set({ shortcutMapById: shortcutMap });
},
}))
);
const unknownShortcut: Shortcut = Shortcut.fromPartial({
id: -1,
name: "Unknown",
});
export const getShortcutUpdateMask = (shortcut: Shortcut, updatingShortcut: Shortcut) => {
const updateMask: string[] = [];
if (!isEqual(shortcut.name, updatingShortcut.name)) {
updateMask.push("name");
}
if (!isEqual(shortcut.link, updatingShortcut.link)) {
updateMask.push("link");
}
if (!isEqual(shortcut.title, updatingShortcut.title)) {
updateMask.push("title");
}
if (!isEqual(shortcut.description, updatingShortcut.description)) {
updateMask.push("description");
}
if (!isEqual(shortcut.tags, updatingShortcut.tags)) {
updateMask.push("tags");
}
if (!isEqual(shortcut.visibility, updatingShortcut.visibility)) {
updateMask.push("visibility");
}
if (!isEqual(shortcut.ogMetadata, updatingShortcut.ogMetadata)) {
updateMask.push("og_metadata");
}
return updateMask;
};
export default useShortcutStore; export default useShortcutStore;

View File

@ -1,33 +1,26 @@
import { create } from "zustand"; import { create } from "zustand";
import { userSettingServiceClient } from "@/grpcweb"; import { authServiceClient, userServiceClient, userSettingServiceClient } from "@/grpcweb";
import { User } from "@/types/proto/api/v2/user_service";
import { UserSetting } from "@/types/proto/api/v2/user_setting_service"; import { UserSetting } from "@/types/proto/api/v2/user_setting_service";
import * as api from "../../helpers/api";
const convertResponseModelUser = (user: User): User => {
return {
...user,
createdTs: user.createdTs * 1000,
updatedTs: user.updatedTs * 1000,
};
};
interface UserState { interface UserState {
userMapById: Record<UserId, User>; userMapById: Record<number, User>;
userSettingMapById: Record<UserId, UserSetting>; userSettingMapById: Record<number, UserSetting>;
currentUserId?: UserId; currentUserId?: number;
// User related actions. // User related actions.
fetchUserList: () => Promise<User[]>; fetchUserList: () => Promise<User[]>;
fetchCurrentUser: () => Promise<User>; fetchCurrentUser: () => Promise<User>;
getOrFetchUserById: (id: UserId) => Promise<User>; getOrFetchUserById: (id: number) => Promise<User>;
getUserById: (id: UserId) => User; getUserById: (id: number) => User;
getCurrentUser: () => User; getCurrentUser: () => User;
createUser: (userCreate: UserCreate) => Promise<User>; setCurrentUserId: (id: number) => void;
patchUser: (userPatch: UserPatch) => Promise<void>; createUser: (create: Partial<User>) => Promise<User>;
deleteUser: (id: UserId) => Promise<void>; patchUser: (userPatch: Partial<User>) => Promise<void>;
deleteUser: (id: number) => Promise<void>;
// User setting related actions. // User setting related actions.
fetchUserSetting: (userId: UserId) => Promise<UserSetting>; fetchUserSetting: (userId: number) => Promise<UserSetting>;
updateUserSetting: (userSetting: UserSetting, updateMask: string[]) => Promise<UserSetting>; updateUserSetting: (userSetting: UserSetting, updateMask: string[]) => Promise<UserSetting>;
getCurrentUserSetting: () => UserSetting; getCurrentUserSetting: () => UserSetting;
} }
@ -36,65 +29,87 @@ const useUserStore = create<UserState>()((set, get) => ({
userMapById: {}, userMapById: {},
userSettingMapById: {}, userSettingMapById: {},
fetchUserList: async () => { fetchUserList: async () => {
const { data: userList } = await api.getUserList(); const { users } = await userServiceClient.listUsers({});
const userMap = get().userMapById; const userMap = get().userMapById;
userList.forEach((user) => { users.forEach((user) => {
userMap[user.id] = convertResponseModelUser(user); userMap[user.id] = user;
}); });
set(userMap); set(userMap);
return userList; return users;
}, },
fetchCurrentUser: async () => { fetchCurrentUser: async () => {
const { data } = await api.getMyselfUser(); const { user } = await authServiceClient.getAuthStatus({});
const user = convertResponseModelUser(data); if (!user) {
throw new Error("User not found");
}
const userMap = get().userMapById; const userMap = get().userMapById;
userMap[user.id] = user; userMap[user.id] = user;
set({ userMapById: userMap, currentUserId: user.id }); set({ userMapById: userMap, currentUserId: user.id });
return user; return user;
}, },
getOrFetchUserById: async (id: UserId) => { getOrFetchUserById: async (id: number) => {
const userMap = get().userMapById; const userMap = get().userMapById;
if (userMap[id]) { if (userMap[id]) {
return userMap[id] as User; return userMap[id] as User;
} }
const { data } = await api.getUserById(id); const { user } = await userServiceClient.getUser({
const user = convertResponseModelUser(data); id: Number(id),
});
if (!user) {
throw new Error("User not found");
}
userMap[id] = user; userMap[id] = user;
set(userMap); set(userMap);
return user; return user;
}, },
createUser: async (userCreate: UserCreate) => { createUser: async (userCreate: Partial<User>) => {
const { data } = await api.createUser(userCreate); const { user } = await userServiceClient.createUser({
const user = convertResponseModelUser(data); user: userCreate,
});
if (!user) {
throw new Error("User not found");
}
const userMap = get().userMapById; const userMap = get().userMapById;
userMap[user.id] = user; userMap[user.id] = user;
set(userMap); set(userMap);
return user; return user;
}, },
patchUser: async (userPatch: UserPatch) => { patchUser: async (userPatch: Partial<User>) => {
const { data } = await api.patchUser(userPatch); const { user } = await userServiceClient.updateUser({
const user = convertResponseModelUser(data); user: userPatch,
updateMask: ["email", "nickname"],
});
if (!user) {
throw new Error("User not found");
}
const userMap = get().userMapById; const userMap = get().userMapById;
userMap[user.id] = user; userMap[user.id] = user;
set(userMap); set(userMap);
}, },
deleteUser: async (userId: UserId) => { deleteUser: async (userId: number) => {
await api.deleteUser(userId); await userServiceClient.deleteUser({
id: userId,
});
const userMap = get().userMapById; const userMap = get().userMapById;
delete userMap[userId]; delete userMap[userId];
set(userMap); set(userMap);
}, },
getUserById: (id: UserId) => { getUserById: (id: number) => {
const userMap = get().userMapById; const userMap = get().userMapById;
return userMap[id] as User; return userMap[id] || unknownUser;
}, },
getCurrentUser: () => { getCurrentUser: () => {
const userMap = get().userMapById; const userMap = get().userMapById;
const currentUserId = get().currentUserId; const currentUserId = get().currentUserId;
return userMap[currentUserId as UserId]; return userMap[currentUserId as number];
}, },
fetchUserSetting: async (userId: UserId) => { setCurrentUserId: (id: number) => {
set({
currentUserId: id,
});
},
fetchUserSetting: async (userId: number) => {
const userSetting = ( const userSetting = (
await userSettingServiceClient.getUserSetting({ await userSettingServiceClient.getUserSetting({
id: userId, id: userId,
@ -122,8 +137,14 @@ const useUserStore = create<UserState>()((set, get) => ({
getCurrentUserSetting: () => { getCurrentUserSetting: () => {
const userSettingMap = get().userSettingMapById; const userSettingMap = get().userSettingMapById;
const currentUserId = get().currentUserId; const currentUserId = get().currentUserId;
return userSettingMap[currentUserId as UserId]; return userSettingMap[currentUserId as number];
}, },
})); }));
const unknownUser: User = User.fromPartial({
id: -1,
email: "Unknown",
nickname: "Unknown",
});
export default useUserStore; export default useUserStore;

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