mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 13:12:36 +00:00
Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
a379614cd9 | |||
c18bbfd0bb | |||
d798b2c5fb | |||
4e3ca8ceb4 | |||
96a68ab117 | |||
0eea0a92db | |||
4a47010608 | |||
fa504a88e5 | |||
de51e1a8d3 | |||
49cc1e9755 | |||
ce5c4b65d3 | |||
cee6c7c401 | |||
b6839d2b7d | |||
0ebf03eb9b | |||
21eab35e45 | |||
fd1168e1dc | |||
5ee32d2e78 | |||
2db9c1e850 | |||
953ec3dbc0 | |||
fc28473aee | |||
c42c543618 | |||
72106d13de | |||
6bbf2df8e0 | |||
d42d3fbe10 | |||
6dfccb9509 | |||
66876452e1 | |||
6b107924aa | |||
b84620c057 | |||
c30b6adb8e | |||
c8fea442d6 | |||
a36a99e53d | |||
86078b097d | |||
11205566ac | |||
709118464b | |||
792b60c480 | |||
1418fc2209 | |||
53c1d8fa91 | |||
b32fdbfc0a | |||
db2aebcf57 | |||
b4e23fc8a0 | |||
7ab66113ac | |||
2909676ed3 | |||
5af9236c19 | |||
04c0f47559 | |||
a91997683b | |||
014dd7d660 | |||
a1b633e4db | |||
57496c9b46 | |||
c4f38f1de6 | |||
e7cf0c2f79 | |||
15ffd0738c | |||
21ff8ba797 |
@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: stevenlgtm
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -41,4 +41,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/slash:latest, stevenlgtm/slash:${{ env.VERSION }}
|
tags: yourselfhosted/slash:latest, yourselfhosted/slash:${{ env.VERSION }}
|
||||||
|
@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: stevenlgtm
|
username: yourselfhosted
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@ -34,4 +34,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/slash:test
|
tags: yourselfhosted/slash:test
|
||||||
|
20
README.md
20
README.md
@ -2,21 +2,27 @@
|
|||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
**Slash** is a bookmarking and short link service that allows you to save and share links easily. It lets you store and categorize links, generate short URLs for easy sharing, search and filter your saved links, and access them from any device.
|
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them using custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
||||||
|
|
||||||
Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
|
Try it out on <a href="https://slash.yourselfhosted.com">Live Demo</a>.
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></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>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Create customizable `/s/` short links for any URL.
|
- Create customizable `/s/` short links for any URL.
|
||||||
- Share short links privately or with others.
|
- Share short links privately or with teammates.
|
||||||
- View analytics on short link traffic and sources.
|
- View analytics on link traffic and sources.
|
||||||
- Open source self-hosted solution.
|
- Open source self-hosted solution.
|
||||||
|
|
||||||
## Deploy with Docker in seconds
|
## Deploy with Docker in seconds
|
||||||
|
|
||||||
> This project is under active development.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash stevenlgtm/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).
|
||||||
|
@ -23,14 +23,11 @@ const (
|
|||||||
apiTokenDuration = 2 * time.Hour
|
apiTokenDuration = 2 * time.Hour
|
||||||
accessTokenDuration = 24 * time.Hour
|
accessTokenDuration = 24 * time.Hour
|
||||||
refreshTokenDuration = 7 * 24 * time.Hour
|
refreshTokenDuration = 7 * 24 * time.Hour
|
||||||
// RefreshThresholdDuration is the threshold duration for refreshing token.
|
|
||||||
RefreshThresholdDuration = 1 * time.Hour
|
|
||||||
|
|
||||||
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
// CookieExpDuration expires slightly earlier than the jwt expiration. Client would be logged out if the user
|
||||||
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
// cookie expires, thus the client would always logout first before attempting to make a request with the expired jwt.
|
||||||
// Suppose we have a valid refresh token, we will refresh the token in 2 cases:
|
// Suppose we have a valid refresh token, we will refresh the token in the following cases:
|
||||||
// 1. The access token is about to expire in <<refreshThresholdDuration>>
|
// 1. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
||||||
// 2. The access token has already expired, we refresh the token so that the ongoing request can pass through.
|
|
||||||
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
||||||
// AccessTokenCookieName is the cookie name of access token.
|
// AccessTokenCookieName is the cookie name of access token.
|
||||||
AccessTokenCookieName = "slash.access-token"
|
AccessTokenCookieName = "slash.access-token"
|
||||||
|
@ -5,7 +5,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/boojack/slash/api/v1/auth"
|
"github.com/boojack/slash/api/v1/auth"
|
||||||
"github.com/boojack/slash/internal/util"
|
"github.com/boojack/slash/internal/util"
|
||||||
@ -88,6 +87,7 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
|
if util.HasPrefixes(path, "/s/*") && method == http.MethodGet {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,23 +103,26 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
}
|
}
|
||||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||||
})
|
})
|
||||||
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
generateToken := false
|
||||||
}
|
|
||||||
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ve *jwt.ValidationError
|
var ve *jwt.ValidationError
|
||||||
if errors.As(err, &ve) {
|
if errors.As(err, &ve) {
|
||||||
// If expiration error is the only error, we will clear the err
|
// If expiration error is the only error, we will ignore the err
|
||||||
// and generate new access token and refresh token
|
// and generate new access token and refresh token.
|
||||||
if ve.Errors == jwt.ValidationErrorExpired {
|
if ve.Errors == jwt.ValidationErrorExpired {
|
||||||
generateToken = true
|
generateToken = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
auth.RemoveTokensAndCookies(c)
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName))
|
||||||
|
}
|
||||||
|
|
||||||
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
// We either have a valid access token or we will attempt to generate new access token and refresh token
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
userID, err := strconv.Atoi(claims.Subject)
|
userID, err := strconv.Atoi(claims.Subject)
|
||||||
|
@ -3,8 +3,10 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/slash/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@ -42,11 +44,44 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isValidURLString(shortcut.Link) {
|
return redirectToShortcut(c, shortcut)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error {
|
||||||
|
isValidURL := isValidURLString(shortcut.Link)
|
||||||
|
if shortcut.OpenGraphMetadata == nil || (shortcut.OpenGraphMetadata.Title == "" && shortcut.OpenGraphMetadata.Description == "" && shortcut.OpenGraphMetadata.Image == "") {
|
||||||
|
if isValidURL {
|
||||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||||
}
|
}
|
||||||
return c.String(http.StatusOK, 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.OpenGraphMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OpenGraphMetadata.Image),
|
||||||
|
`<meta property="og:type" content="website" />`,
|
||||||
|
// Twitter related metadata.
|
||||||
|
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
|
||||||
|
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
|
||||||
|
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OpenGraphMetadata.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 *store.Shortcut) error {
|
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *store.Shortcut) error {
|
||||||
|
@ -30,6 +30,12 @@ func (v Visibility) String() string {
|
|||||||
return string(v)
|
return string(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
|
|
||||||
@ -41,29 +47,32 @@ type Shortcut struct {
|
|||||||
RowStatus RowStatus `json:"rowStatus"`
|
RowStatus RowStatus `json:"rowStatus"`
|
||||||
|
|
||||||
// Domain specific fields
|
// Domain specific fields
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
View int `json:"view"`
|
View int `json:"view"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateShortcutRequest struct {
|
type CreateShortcutRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Visibility Visibility `json:"visibility"`
|
Visibility Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PatchShortcutRequest struct {
|
type PatchShortcutRequest struct {
|
||||||
RowStatus *RowStatus `json:"rowStatus"`
|
RowStatus *RowStatus `json:"rowStatus"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Link *string `json:"link"`
|
Link *string `json:"link"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Visibility *Visibility `json:"visibility"`
|
Visibility *Visibility `json:"visibility"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||||
@ -85,6 +94,11 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
Description: create.Description,
|
Description: create.Description,
|
||||||
Visibility: store.Visibility(create.Visibility.String()),
|
Visibility: store.Visibility(create.Visibility.String()),
|
||||||
Tag: strings.Join(create.Tags, " "),
|
Tag: strings.Join(create.Tags, " "),
|
||||||
|
OpenGraphMetadata: &store.OpenGraphMetadata{
|
||||||
|
Title: create.OpenGraphMetadata.Title,
|
||||||
|
Description: create.OpenGraphMetadata.Description,
|
||||||
|
Image: create.OpenGraphMetadata.Image,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
||||||
@ -156,6 +170,13 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
tag := strings.Join(patch.Tags, " ")
|
tag := strings.Join(patch.Tags, " ")
|
||||||
shortcutUpdate.Tag = &tag
|
shortcutUpdate.Tag = &tag
|
||||||
}
|
}
|
||||||
|
if patch.OpenGraphMetadata != nil {
|
||||||
|
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||||
|
Title: patch.OpenGraphMetadata.Title,
|
||||||
|
Description: patch.OpenGraphMetadata.Description,
|
||||||
|
Image: patch.OpenGraphMetadata.Image,
|
||||||
|
}
|
||||||
|
}
|
||||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||||
@ -261,37 +282,14 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||||
ID: shortcutID,
|
if err != nil {
|
||||||
}); err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, true)
|
return c.JSON(http.StatusOK, true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.Shortcut) error {
|
|
||||||
payload := &ActivityShorcutCreatePayload{
|
|
||||||
ShortcutID: shortcut.ID,
|
|
||||||
}
|
|
||||||
payloadStr, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
|
||||||
}
|
|
||||||
activity := &store.Activity{
|
|
||||||
CreatorID: shortcut.CreatorID,
|
|
||||||
Type: store.ActivityShortcutCreate,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Payload: string(payloadStr),
|
|
||||||
}
|
|
||||||
_, err = s.Store.CreateActivity(ctx, activity)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "Failed to create activity")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||||
if shortcut == nil {
|
if shortcut == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -338,5 +336,31 @@ func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
|||||||
Visibility: Visibility(shortcut.Visibility),
|
Visibility: Visibility(shortcut.Visibility),
|
||||||
RowStatus: RowStatus(shortcut.RowStatus),
|
RowStatus: RowStatus(shortcut.RowStatus),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
OpenGraphMetadata: &OpenGraphMetadata{
|
||||||
|
Title: shortcut.OpenGraphMetadata.Title,
|
||||||
|
Description: shortcut.OpenGraphMetadata.Description,
|
||||||
|
Image: shortcut.OpenGraphMetadata.Image,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.Shortcut) error {
|
||||||
|
payload := &ActivityShorcutCreatePayload{
|
||||||
|
ShortcutID: shortcut.ID,
|
||||||
|
}
|
||||||
|
payloadStr, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||||
|
}
|
||||||
|
activity := &store.Activity{
|
||||||
|
CreatorID: shortcut.CreatorID,
|
||||||
|
Type: store.ActivityShortcutCreate,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Payload: string(payloadStr),
|
||||||
|
}
|
||||||
|
_, err = s.Store.CreateActivity(ctx, activity)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Failed to create activity")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -30,7 +30,7 @@ var (
|
|||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "slash",
|
Use: "slash",
|
||||||
Short: `A bookmarking and url shortener, save and share your links very easily.`,
|
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(profile)
|
db := db.NewDB(profile)
|
||||||
|
39
docs/install.md
Normal file
39
docs/install.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Self-hosting Slash with Docker
|
||||||
|
|
||||||
|
Slash is designed for self-hosting through Docker. No Docker expertise is required to launch your own instance. Just basic understanding of command line and networking.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
The only requirement is a server with Docker installed.
|
||||||
|
|
||||||
|
## Docker Run
|
||||||
|
|
||||||
|
To deploy Slash using docker run, just one command is needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash yourselfhosted/slash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
To upgrade Slash to latest version, stop and remove the old container first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop slash && docker rm slash
|
||||||
|
```
|
||||||
|
|
||||||
|
It's recommended but optional to backup database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r ~/.slash/slash_prod.db ~/.slash/slash_prod.db.bak
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pull the latest image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull yourselfhosted/slash:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
@ -9,10 +9,10 @@ import (
|
|||||||
|
|
||||||
// Version is the service current released version.
|
// Version is the service current released version.
|
||||||
// Semantic versioning: https://semver.org/
|
// Semantic versioning: https://semver.org/
|
||||||
var Version = "0.2.0"
|
var Version = "0.3.1"
|
||||||
|
|
||||||
// DevVersion is the service current development version.
|
// DevVersion is the service current development version.
|
||||||
var DevVersion = "0.2.0"
|
var DevVersion = "0.3.1"
|
||||||
|
|
||||||
func GetCurrentVersion(mode string) string {
|
func GetCurrentVersion(mode string) string {
|
||||||
if mode == "dev" || mode == "demo" {
|
if mode == "dev" || mode == "demo" {
|
||||||
|
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -64,13 +63,7 @@ type FindActivity struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO activity (
|
INSERT INTO activity (
|
||||||
creator_id,
|
creator_id,
|
||||||
type,
|
type,
|
||||||
@ -80,7 +73,7 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, created_ts
|
RETURNING id, created_ts
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query,
|
if err := s.db.QueryRowContext(ctx, stmt,
|
||||||
create.CreatorID,
|
create.CreatorID,
|
||||||
create.Type.String(),
|
create.Type.String(),
|
||||||
create.Level.String(),
|
create.Level.String(),
|
||||||
@ -92,50 +85,11 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := create
|
activity := create
|
||||||
return activity, nil
|
return activity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
|
func (s *Store) ListActivities(ctx context.Context, find *FindActivity) ([]*Activity, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listActivities(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listActivities(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := list[0]
|
|
||||||
return activity, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Activity, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
if find.Type != "" {
|
if find.Type != "" {
|
||||||
where, args = append(where, "type = ?"), append(args, find.Type.String())
|
where, args = append(where, "type = ?"), append(args, find.Type.String())
|
||||||
@ -157,11 +111,10 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
|||||||
payload
|
payload
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
list := []*Activity{}
|
list := []*Activity{}
|
||||||
@ -187,3 +140,17 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
|||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetActivity(ctx context.Context, find *FindActivity) (*Activity, error) {
|
||||||
|
list, err := s.ListActivities(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := list[0]
|
||||||
|
return activity, nil
|
||||||
|
}
|
||||||
|
@ -23,12 +23,12 @@ var migrationFS embed.FS
|
|||||||
var seedFS embed.FS
|
var seedFS embed.FS
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
profile *profile.Profile
|
|
||||||
// sqlite db connection instance
|
// sqlite db connection instance
|
||||||
DBInstance *sql.DB
|
DBInstance *sql.DB
|
||||||
|
profile *profile.Profile
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDB returns a new instance of DB.
|
// NewDB returns a new instance of DB associated with the given datasource name.
|
||||||
func NewDB(profile *profile.Profile) *DB {
|
func NewDB(profile *profile.Profile) *DB {
|
||||||
db := &DB{
|
db := &DB{
|
||||||
profile: profile,
|
profile: profile,
|
||||||
@ -42,8 +42,21 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
return fmt.Errorf("dsn required")
|
return fmt.Errorf("dsn required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to the database without foreign_key.
|
// Connect to the database with some sane settings:
|
||||||
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
|
// - No shared-cache: it's obsolete; WAL journal mode is a better solution.
|
||||||
|
// - No foreign key constraints: it's currently disabled by default, but it's a
|
||||||
|
// good practice to be explicit and prevent future surprises on SQLite upgrades.
|
||||||
|
// - Journal mode set to WAL: it's the recommended journal mode for most applications
|
||||||
|
// as it prevents locking issues.
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - When using the `modernc.org/sqlite` driver, each pragma must be prefixed with `_pragma=`.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// - https://pkg.go.dev/modernc.org/sqlite#Driver.Open
|
||||||
|
// - https://www.sqlite.org/sharedcache.html
|
||||||
|
// - https://www.sqlite.org/pragma.html
|
||||||
|
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?_pragma=foreign_keys(0)&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
return fmt.Errorf("failed to open db with dsn: %s, err: %w", db.profile.DSN, err)
|
||||||
}
|
}
|
||||||
@ -52,16 +65,16 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
if db.profile.Mode == "prod" {
|
if db.profile.Mode == "prod" {
|
||||||
_, err := os.Stat(db.profile.DSN)
|
_, err := os.Stat(db.profile.DSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If db file not exists, we should apply the latest schema.
|
// If db file not exists, we should create a new one with latest schema.
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
if err := db.applyLatestSchema(ctx); err != nil {
|
if err := db.applyLatestSchema(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to apply latest schema: %w", err)
|
return fmt.Errorf("failed to apply latest schema, err: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("failed to check database file: %w", err)
|
return fmt.Errorf("failed to get db file stat, err: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If db file exists, we should check the migration history and apply the migration if needed.
|
// If db file exists, we should check if we need to migrate the database.
|
||||||
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
currentVersion := version.GetCurrentVersion(db.profile.Mode)
|
||||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -177,21 +190,15 @@ func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.DBInstance.Begin()
|
// Upsert the newest version to migration_history.
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// upsert the newest version to migration_history
|
|
||||||
version := minorVersion + ".0"
|
version := minorVersion + ".0"
|
||||||
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
|
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||||
Version: version,
|
Version: version,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
|
return fmt.Errorf("failed to upsert migration history with version: %s, err: %w", version, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) seed(ctx context.Context) error {
|
func (db *DB) seed(ctx context.Context) error {
|
||||||
@ -218,17 +225,11 @@ func (db *DB) seed(ctx context.Context) error {
|
|||||||
|
|
||||||
// execute runs a single SQL statement within a transaction.
|
// execute runs a single SQL statement within a transaction.
|
||||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||||
tx, err := db.DBInstance.Begin()
|
if _, err := db.DBInstance.ExecContext(ctx, stmt); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("failed to execute statement, err: %w", err)
|
return fmt.Errorf("failed to execute statement, err: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// minorDirRegexp is a regular expression for minor version directory.
|
// minorDirRegexp is a regular expression for minor version directory.
|
||||||
|
@ -43,7 +43,8 @@ CREATE TABLE shortcut (
|
|||||||
link TEXT NOT NULL,
|
link TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
||||||
tag TEXT NOT NULL DEFAULT ''
|
tag TEXT NOT NULL DEFAULT '',
|
||||||
|
og_metadata TEXT NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
||||||
|
1
store/db/migration/prod/0.3/00__add_og_metadata.sql
Normal file
1
store/db/migration/prod/0.3/00__add_og_metadata.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shortcut ADD COLUMN og_metadata TEXT NOT NULL DEFAULT '{}';
|
@ -43,7 +43,8 @@ CREATE TABLE shortcut (
|
|||||||
link TEXT NOT NULL,
|
link TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
visibility TEXT NOT NULL CHECK (visibility IN ('PRIVATE', 'WORKSPACE', 'PUBLIC')) DEFAULT 'PRIVATE',
|
||||||
tag TEXT NOT NULL DEFAULT ''
|
tag TEXT NOT NULL DEFAULT '',
|
||||||
|
og_metadata TEXT NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
CREATE INDEX idx_shortcut_name ON shortcut(name);
|
||||||
|
@ -2,7 +2,6 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,47 +19,13 @@ type MigrationHistoryFind struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
func (db *DB) FindMigrationHistoryList(ctx context.Context, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
||||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := findMigrationHistoryList(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
|
||||||
tx, err := db.DBInstance.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
migrationHistory, err := upsertMigrationHistory(ctx, tx, upsert)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return migrationHistory, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.Version; v != nil {
|
if v := find.Version; v != nil {
|
||||||
where, args = append(where, "version = ?"), append(args, *v)
|
where, args = append(where, "version = ?"), append(args, *v)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
SELECT
|
SELECT
|
||||||
version,
|
version,
|
||||||
created_ts
|
created_ts
|
||||||
@ -69,7 +34,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
ORDER BY created_ts DESC
|
ORDER BY created_ts DESC
|
||||||
`
|
`
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := db.DBInstance.QueryContext(ctx, stmt, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -84,7 +49,6 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +59,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
|||||||
return migrationHistoryList, nil
|
return migrationHistoryList, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO migration_history (
|
INSERT INTO migration_history (
|
||||||
version
|
version
|
||||||
@ -107,7 +71,7 @@ func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHi
|
|||||||
RETURNING version, created_ts
|
RETURNING version, created_ts
|
||||||
`
|
`
|
||||||
migrationHistory := &MigrationHistory{}
|
migrationHistory := &MigrationHistory{}
|
||||||
if err := tx.QueryRowContext(ctx, query, upsert.Version).Scan(
|
if err := db.DBInstance.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||||
&migrationHistory.Version,
|
&migrationHistory.Version,
|
||||||
&migrationHistory.CreatedTs,
|
&migrationHistory.CreatedTs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
@ -10,10 +10,9 @@ VALUES
|
|||||||
(
|
(
|
||||||
101,
|
101,
|
||||||
'ADMIN',
|
'ADMIN',
|
||||||
'slash@stevenlgtm.com',
|
'slash@yourselfhosted.com',
|
||||||
'Slasher',
|
'Slasher',
|
||||||
-- raw password: secret
|
'$2a$10$H8HBWGcG/hoePhFy5SiNKOHxMD6omIpyEEWbl/fIorFC814bXW.Ua'
|
||||||
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
|
@ -10,8 +10,8 @@ VALUES
|
|||||||
(
|
(
|
||||||
1,
|
1,
|
||||||
101,
|
101,
|
||||||
'memos',
|
'discord',
|
||||||
'https://usememos.com',
|
'https://discord.gg/QZqUuUAhDV',
|
||||||
'PUBLIC'
|
'PUBLIC'
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -21,14 +21,58 @@ INSERT INTO
|
|||||||
`creator_id`,
|
`creator_id`,
|
||||||
`name`,
|
`name`,
|
||||||
`link`,
|
`link`,
|
||||||
`visibility`
|
`visibility`,
|
||||||
|
`tag`,
|
||||||
|
`og_metadata`
|
||||||
)
|
)
|
||||||
VALUES
|
VALUES
|
||||||
(
|
(
|
||||||
2,
|
2,
|
||||||
101,
|
101,
|
||||||
|
'ai-infra',
|
||||||
|
'https://star-history.com/blog/open-source-ai-infra-projects',
|
||||||
|
'PUBLIC',
|
||||||
|
'star-history ai',
|
||||||
|
'{"title":"Open Source AI Infra for Your Next Project","description":"Some open-source infra projects that can be directly used for your next project. 💡","image":"https://star-history.com/blog/assets/open-source-ai-infra-projects/banner.webp"}'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`,
|
||||||
|
`tag`,
|
||||||
|
`og_metadata`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
3,
|
||||||
|
101,
|
||||||
|
'schema-change',
|
||||||
|
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
|
||||||
|
'PUBLIC',
|
||||||
|
'database article👍',
|
||||||
|
'{"title":"How to Handle Database Migration / Schema Change?","description":"A database schema is the structure of a database, which describes the relationships between the different tables and fields in the database. A database schema change, also known as schema migration, or simply migration refers to any alteration to this structure, such as adding a new table, modifying the data type of a field, or changing the relationships between tables.","image":"https://www.bytebase.com/_next/image/?url=%2Fcontent%2Fblog%2Fhow-to-handle-database-schema-change%2Fchange.webp\u0026w=2048\u0026q=75"}'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`tag`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
101,
|
||||||
'sqlchat',
|
'sqlchat',
|
||||||
'https://www.sqlchat.ai',
|
'https://www.sqlchat.ai',
|
||||||
|
'ai chatbot sql',
|
||||||
'WORKSPACE'
|
'WORKSPACE'
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -42,24 +86,7 @@ INSERT INTO
|
|||||||
)
|
)
|
||||||
VALUES
|
VALUES
|
||||||
(
|
(
|
||||||
3,
|
5,
|
||||||
101,
|
|
||||||
'schema-change',
|
|
||||||
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
|
|
||||||
'PUBLIC'
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO
|
|
||||||
shortcut (
|
|
||||||
`id`,
|
|
||||||
`creator_id`,
|
|
||||||
`name`,
|
|
||||||
`link`,
|
|
||||||
`visibility`
|
|
||||||
)
|
|
||||||
VALUES
|
|
||||||
(
|
|
||||||
4,
|
|
||||||
102,
|
102,
|
||||||
'stevenlgtm',
|
'stevenlgtm',
|
||||||
'https://github.com/boojack',
|
'https://github.com/boojack',
|
||||||
|
@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -31,6 +32,12 @@ func (e Visibility) String() string {
|
|||||||
return "PRIVATE"
|
return "PRIVATE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenGraphMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
ID int
|
ID int
|
||||||
|
|
||||||
@ -41,22 +48,24 @@ type Shortcut struct {
|
|||||||
RowStatus RowStatus
|
RowStatus RowStatus
|
||||||
|
|
||||||
// Domain specific fields
|
// Domain specific fields
|
||||||
Name string
|
Name string
|
||||||
Link string
|
Link string
|
||||||
Description string
|
Description string
|
||||||
Visibility Visibility
|
Visibility Visibility
|
||||||
Tag string
|
Tag string
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateShortcut struct {
|
type UpdateShortcut struct {
|
||||||
ID int
|
ID int
|
||||||
|
|
||||||
RowStatus *RowStatus
|
RowStatus *RowStatus
|
||||||
Name *string
|
Name *string
|
||||||
Link *string
|
Link *string
|
||||||
Description *string
|
Description *string
|
||||||
Visibility *Visibility
|
Visibility *Visibility
|
||||||
Tag *string
|
Tag *string
|
||||||
|
OpenGraphMetadata *OpenGraphMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
type FindShortcut struct {
|
type FindShortcut struct {
|
||||||
@ -73,24 +82,27 @@ type DeleteShortcut struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut, error) {
|
func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
set := []string{"creator_id", "name", "link", "description", "visibility", "tag"}
|
set := []string{"creator_id", "name", "link", "description", "visibility", "tag"}
|
||||||
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
||||||
placeholder := []string{"?", "?", "?", "?", "?", "?"}
|
placeholder := []string{"?", "?", "?", "?", "?", "?"}
|
||||||
|
if create.OpenGraphMetadata != nil {
|
||||||
|
set = append(set, "og_metadata")
|
||||||
|
openGraphMetadataBytes, err := json.Marshal(create.OpenGraphMetadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args = append(args, string(openGraphMetadataBytes))
|
||||||
|
placeholder = append(placeholder, "?")
|
||||||
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
INSERT INTO shortcut (
|
INSERT INTO shortcut (
|
||||||
` + strings.Join(set, ", ") + `
|
` + strings.Join(set, ", ") + `
|
||||||
)
|
)
|
||||||
VALUES (` + strings.Join(placeholder, ",") + `)
|
VALUES (` + strings.Join(placeholder, ",") + `)
|
||||||
RETURNING id, created_ts, updated_ts, row_status
|
RETURNING id, created_ts, updated_ts, row_status
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&create.ID,
|
&create.ID,
|
||||||
&create.CreatedTs,
|
&create.CreatedTs,
|
||||||
&create.UpdatedTs,
|
&create.UpdatedTs,
|
||||||
@ -99,20 +111,10 @@ func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return create, nil
|
return create, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Shortcut, error) {
|
func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Shortcut, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
set, args := []string{}, []any{}
|
set, args := []string{}, []any{}
|
||||||
if update.RowStatus != nil {
|
if update.RowStatus != nil {
|
||||||
set, args = append(set, "row_status = ?"), append(args, update.RowStatus.String())
|
set, args = append(set, "row_status = ?"), append(args, update.RowStatus.String())
|
||||||
@ -132,21 +134,29 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
|||||||
if update.Tag != nil {
|
if update.Tag != nil {
|
||||||
set, args = append(set, "tag = ?"), append(args, *update.Tag)
|
set, args = append(set, "tag = ?"), append(args, *update.Tag)
|
||||||
}
|
}
|
||||||
|
if update.OpenGraphMetadata != nil {
|
||||||
|
openGraphMetadataBytes, err := json.Marshal(update.OpenGraphMetadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set, args = append(set, "og_metadata = ?"), append(args, string(openGraphMetadataBytes))
|
||||||
|
}
|
||||||
if len(set) == 0 {
|
if len(set) == 0 {
|
||||||
return nil, fmt.Errorf("no update specified")
|
return nil, fmt.Errorf("no update specified")
|
||||||
}
|
}
|
||||||
args = append(args, update.ID)
|
args = append(args, update.ID)
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
UPDATE shortcut
|
UPDATE shortcut
|
||||||
SET
|
SET
|
||||||
` + strings.Join(set, ", ") + `
|
` + strings.Join(set, ", ") + `
|
||||||
WHERE
|
WHERE
|
||||||
id = ?
|
id = ?
|
||||||
RETURNING id, creator_id, created_ts, updated_ts, row_status, name, link, description, visibility, tag
|
RETURNING id, creator_id, created_ts, updated_ts, row_status, name, link, description, visibility, tag, og_metadata
|
||||||
`
|
`
|
||||||
shortcut := &Shortcut{}
|
shortcut := &Shortcut{}
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
openGraphMetadataString := ""
|
||||||
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&shortcut.ID,
|
&shortcut.ID,
|
||||||
&shortcut.CreatorID,
|
&shortcut.CreatorID,
|
||||||
&shortcut.CreatedTs,
|
&shortcut.CreatedTs,
|
||||||
@ -157,12 +167,15 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
|||||||
&shortcut.Description,
|
&shortcut.Description,
|
||||||
&shortcut.Visibility,
|
&shortcut.Visibility,
|
||||||
&shortcut.Tag,
|
&shortcut.Tag,
|
||||||
|
&openGraphMetadataString,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if openGraphMetadataString != "" {
|
||||||
if err := tx.Commit(); err != nil {
|
shortcut.OpenGraphMetadata = &OpenGraphMetadata{}
|
||||||
return nil, err
|
if err := json.Unmarshal([]byte(openGraphMetadataString), shortcut.OpenGraphMetadata); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
s.shortcutCache.Store(shortcut.ID, shortcut)
|
||||||
@ -170,73 +183,7 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*Shortcut, error) {
|
func (s *Store) ListShortcuts(ctx context.Context, find *FindShortcut) ([]*Shortcut, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listShortcuts(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, shortcut := range list {
|
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*Shortcut, error) {
|
|
||||||
if find.ID != nil {
|
|
||||||
if cache, ok := s.shortcutCache.Load(*find.ID); ok {
|
|
||||||
return cache.(*Shortcut), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
shortcuts, err := listShortcuts(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(shortcuts) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
shortcut := shortcuts[0]
|
|
||||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
|
||||||
return shortcut, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `DELETE FROM shortcut WHERE id = ?`, delete.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
// do nothing here to prevent linter warning.
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.shortcutCache.Delete(delete.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shortcut, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.ID; v != nil {
|
if v := find.ID; v != nil {
|
||||||
where, args = append(where, "id = ?"), append(args, *v)
|
where, args = append(where, "id = ?"), append(args, *v)
|
||||||
}
|
}
|
||||||
@ -261,7 +208,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
where, args = append(where, "tag LIKE ?"), append(args, "%"+*v+"%")
|
where, args = append(where, "tag LIKE ?"), append(args, "%"+*v+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := tx.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
creator_id,
|
creator_id,
|
||||||
@ -272,7 +219,8 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
link,
|
link,
|
||||||
description,
|
description,
|
||||||
visibility,
|
visibility,
|
||||||
tag
|
tag,
|
||||||
|
og_metadata
|
||||||
FROM shortcut
|
FROM shortcut
|
||||||
WHERE `+strings.Join(where, " AND ")+`
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
ORDER BY created_ts DESC`,
|
ORDER BY created_ts DESC`,
|
||||||
@ -286,6 +234,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
list := make([]*Shortcut, 0)
|
list := make([]*Shortcut, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
shortcut := &Shortcut{}
|
shortcut := &Shortcut{}
|
||||||
|
openGraphMetadataString := ""
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&shortcut.ID,
|
&shortcut.ID,
|
||||||
&shortcut.CreatorID,
|
&shortcut.CreatorID,
|
||||||
@ -297,9 +246,16 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
&shortcut.Description,
|
&shortcut.Description,
|
||||||
&shortcut.Visibility,
|
&shortcut.Visibility,
|
||||||
&shortcut.Tag,
|
&shortcut.Tag,
|
||||||
|
&openGraphMetadataString,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if openGraphMetadataString != "" {
|
||||||
|
shortcut.OpenGraphMetadata = &OpenGraphMetadata{}
|
||||||
|
if err := json.Unmarshal([]byte(openGraphMetadataString), shortcut.OpenGraphMetadata); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
list = append(list, shortcut)
|
list = append(list, shortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,9 +263,43 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, shortcut := range list {
|
||||||
|
s.shortcutCache.Store(shortcut.ID, shortcut)
|
||||||
|
}
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetShortcut(ctx context.Context, find *FindShortcut) (*Shortcut, error) {
|
||||||
|
if find.ID != nil {
|
||||||
|
if cache, ok := s.shortcutCache.Load(*find.ID); ok {
|
||||||
|
return cache.(*Shortcut), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcuts, err := s.ListShortcuts(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(shortcuts) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcut := shortcuts[0]
|
||||||
|
s.shortcutCache.Store(shortcut.ID, shortcut)
|
||||||
|
return shortcut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteShortcut(ctx context.Context, delete *DeleteShortcut) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `DELETE FROM shortcut WHERE id = ?`, delete.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.shortcutCache.Delete(delete.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
|
func vacuumShortcut(ctx context.Context, tx *sql.Tx) error {
|
||||||
stmt := `
|
stmt := `
|
||||||
DELETE FROM
|
DELETE FROM
|
||||||
|
159
store/user.go
159
store/user.go
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -55,13 +54,7 @@ type DeleteUser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user (
|
INSERT INTO user (
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
@ -71,7 +64,7 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
|||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
RETURNING id, created_ts, updated_ts, row_status
|
RETURNING id, created_ts, updated_ts, row_status
|
||||||
`
|
`
|
||||||
if err := tx.QueryRowContext(ctx, query,
|
if err := s.db.QueryRowContext(ctx, stmt,
|
||||||
create.Email,
|
create.Email,
|
||||||
create.Nickname,
|
create.Nickname,
|
||||||
create.PasswordHash,
|
create.PasswordHash,
|
||||||
@ -85,22 +78,12 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := create
|
user := create
|
||||||
s.userCache.Store(user.ID, user)
|
s.userCache.Store(user.ID, user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) {
|
func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
set, args := []string{}, []any{}
|
set, args := []string{}, []any{}
|
||||||
if v := update.RowStatus; v != nil {
|
if v := update.RowStatus; v != nil {
|
||||||
set, args = append(set, "row_status = ?"), append(args, *v)
|
set, args = append(set, "row_status = ?"), append(args, *v)
|
||||||
@ -122,7 +105,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
return nil, fmt.Errorf("no fields to update")
|
return nil, fmt.Errorf("no fields to update")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
stmt := `
|
||||||
UPDATE user
|
UPDATE user
|
||||||
SET ` + strings.Join(set, ", ") + `
|
SET ` + strings.Join(set, ", ") + `
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@ -130,7 +113,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
`
|
`
|
||||||
args = append(args, update.ID)
|
args = append(args, update.ID)
|
||||||
user := &User{}
|
user := &User{}
|
||||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||||
&user.ID,
|
&user.ID,
|
||||||
&user.CreatedTs,
|
&user.CreatedTs,
|
||||||
&user.UpdatedTs,
|
&user.UpdatedTs,
|
||||||
@ -143,23 +126,68 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.userCache.Store(user.ID, user)
|
s.userCache.Store(user.ID, user)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
|
func (s *Store) ListUsers(ctx context.Context, find *FindUser) ([]*User, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
|
if v := find.ID; v != nil {
|
||||||
|
where, args = append(where, "id = ?"), append(args, *v)
|
||||||
|
}
|
||||||
|
if v := find.RowStatus; v != nil {
|
||||||
|
where, args = append(where, "row_status = ?"), append(args, v.String())
|
||||||
|
}
|
||||||
|
if v := find.Email; v != nil {
|
||||||
|
where, args = append(where, "email = ?"), append(args, *v)
|
||||||
|
}
|
||||||
|
if v := find.Nickname; v != nil {
|
||||||
|
where, args = append(where, "nickname = ?"), append(args, *v)
|
||||||
|
}
|
||||||
|
if v := find.Role; v != nil {
|
||||||
|
where, args = append(where, "role = ?"), append(args, *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
created_ts,
|
||||||
|
updated_ts,
|
||||||
|
row_status,
|
||||||
|
email,
|
||||||
|
nickname,
|
||||||
|
password_hash,
|
||||||
|
role
|
||||||
|
FROM user
|
||||||
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
|
ORDER BY updated_ts DESC, created_ts DESC
|
||||||
|
`
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer rows.Close()
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
list := make([]*User, 0)
|
||||||
if err != nil {
|
for rows.Next() {
|
||||||
|
user := &User{}
|
||||||
|
if err := rows.Scan(
|
||||||
|
&user.ID,
|
||||||
|
&user.CreatedTs,
|
||||||
|
&user.UpdatedTs,
|
||||||
|
&user.RowStatus,
|
||||||
|
&user.Email,
|
||||||
|
&user.Nickname,
|
||||||
|
&user.PasswordHash,
|
||||||
|
&user.Role,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,13 +205,7 @@ func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
list, err := s.ListUsers(ctx, find)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -217,7 +239,6 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
// do nothing here to prevent linter warning.
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,67 +246,3 @@ func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
|
||||||
|
|
||||||
if v := find.ID; v != nil {
|
|
||||||
where, args = append(where, "id = ?"), append(args, *v)
|
|
||||||
}
|
|
||||||
if v := find.RowStatus; v != nil {
|
|
||||||
where, args = append(where, "row_status = ?"), append(args, v.String())
|
|
||||||
}
|
|
||||||
if v := find.Email; v != nil {
|
|
||||||
where, args = append(where, "email = ?"), append(args, *v)
|
|
||||||
}
|
|
||||||
if v := find.Nickname; v != nil {
|
|
||||||
where, args = append(where, "nickname = ?"), append(args, *v)
|
|
||||||
}
|
|
||||||
if v := find.Role; v != nil {
|
|
||||||
where, args = append(where, "role = ?"), append(args, *v)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
created_ts,
|
|
||||||
updated_ts,
|
|
||||||
row_status,
|
|
||||||
email,
|
|
||||||
nickname,
|
|
||||||
password_hash,
|
|
||||||
role
|
|
||||||
FROM user
|
|
||||||
WHERE ` + strings.Join(where, " AND ") + `
|
|
||||||
ORDER BY updated_ts DESC, created_ts DESC
|
|
||||||
`
|
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
list := make([]*User, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
user := &User{}
|
|
||||||
if err := rows.Scan(
|
|
||||||
&user.ID,
|
|
||||||
&user.CreatedTs,
|
|
||||||
&user.UpdatedTs,
|
|
||||||
&user.RowStatus,
|
|
||||||
&user.Email,
|
|
||||||
&user.Nickname,
|
|
||||||
&user.PasswordHash,
|
|
||||||
&user.Role,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
list = append(list, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
@ -18,13 +18,7 @@ type FindUserSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*UserSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO user_setting (
|
INSERT INTO user_setting (
|
||||||
user_id, key, value
|
user_id, key, value
|
||||||
)
|
)
|
||||||
@ -32,11 +26,7 @@ func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*Us
|
|||||||
ON CONFLICT(user_id, key) DO UPDATE
|
ON CONFLICT(user_id, key) DO UPDATE
|
||||||
SET value = EXCLUDED.value
|
SET value = EXCLUDED.value
|
||||||
`
|
`
|
||||||
if _, err := tx.ExecContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
if _, err := s.db.ExecContext(ctx, stmt, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,51 +36,6 @@ func (s *Store) UpsertUserSetting(ctx context.Context, upsert *UserSetting) (*Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*UserSetting, error) {
|
func (s *Store) ListUserSettings(ctx context.Context, find *FindUserSetting) ([]*UserSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
userSettingList, err := listUserSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, userSetting := range userSettingList {
|
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserID, userSetting.Key), userSetting)
|
|
||||||
}
|
|
||||||
return userSettingList, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*UserSetting, error) {
|
|
||||||
if find.UserID != nil && find.Key != "" {
|
|
||||||
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key)); ok {
|
|
||||||
return cache.(*UserSetting), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUserSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userSettingMessage := list[0]
|
|
||||||
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserID, userSettingMessage.Key), userSettingMessage)
|
|
||||||
return userSettingMessage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([]*UserSetting, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if v := find.Key; v != "" {
|
if v := find.Key; v != "" {
|
||||||
@ -107,30 +52,54 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
|
|||||||
value
|
value
|
||||||
FROM user_setting
|
FROM user_setting
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
userSettingMessageList := make([]*UserSetting, 0)
|
userSettingList := make([]*UserSetting, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
userSettingMessage := &UserSetting{}
|
userSetting := &UserSetting{}
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&userSettingMessage.UserID,
|
&userSetting.UserID,
|
||||||
&userSettingMessage.Key,
|
&userSetting.Key,
|
||||||
&userSettingMessage.Value,
|
&userSetting.Value,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userSettingMessageList = append(userSettingMessageList, userSettingMessage)
|
userSettingList = append(userSettingList, userSetting)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return userSettingMessageList, nil
|
for _, userSetting := range userSettingList {
|
||||||
|
s.userSettingCache.Store(getUserSettingCacheKey(userSetting.UserID, userSetting.Key), userSetting)
|
||||||
|
}
|
||||||
|
return userSettingList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUserSetting(ctx context.Context, find *FindUserSetting) (*UserSetting, error) {
|
||||||
|
if find.UserID != nil && find.Key != "" {
|
||||||
|
if cache, ok := s.userSettingCache.Load(getUserSettingCacheKey(*find.UserID, find.Key)); ok {
|
||||||
|
return cache.(*UserSetting), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListUserSettings(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userSettingMessage := list[0]
|
||||||
|
s.userSettingCache.Store(getUserSettingCacheKey(userSettingMessage.UserID, userSettingMessage.Key), userSettingMessage)
|
||||||
|
return userSettingMessage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {
|
func vacuumUserSetting(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
@ -2,7 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -17,13 +16,7 @@ const (
|
|||||||
|
|
||||||
// String returns the string format of WorkspaceSettingKey type.
|
// String returns the string format of WorkspaceSettingKey type.
|
||||||
func (key WorkspaceSettingKey) String() string {
|
func (key WorkspaceSettingKey) String() string {
|
||||||
switch key {
|
return string(key)
|
||||||
case WorkspaceDisallowSignUp:
|
|
||||||
return "disallow-signup"
|
|
||||||
case WorkspaceSecretSessionName:
|
|
||||||
return "secret-session-name"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceSetting struct {
|
type WorkspaceSetting struct {
|
||||||
@ -36,13 +29,7 @@ type FindWorkspaceSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
stmt := `
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO workspace_setting (
|
INSERT INTO workspace_setting (
|
||||||
key,
|
key,
|
||||||
value
|
value
|
||||||
@ -51,11 +38,7 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
|||||||
ON CONFLICT(key) DO UPDATE
|
ON CONFLICT(key) DO UPDATE
|
||||||
SET value = EXCLUDED.value
|
SET value = EXCLUDED.value
|
||||||
`
|
`
|
||||||
if _, err := tx.ExecContext(ctx, query, upsert.Key, upsert.Value); err != nil {
|
if _, err := s.db.ExecContext(ctx, stmt, upsert.Key, upsert.Value); err != nil {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,53 +48,8 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListWorkspaceSettings(ctx context.Context, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
|
func (s *Store) ListWorkspaceSettings(ctx context.Context, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listWorkspaceSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, workspaceSetting := range list {
|
|
||||||
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetWorkspaceSetting(ctx context.Context, find *FindWorkspaceSetting) (*WorkspaceSetting, error) {
|
|
||||||
if find.Key != "" {
|
|
||||||
if cache, ok := s.workspaceSettingCache.Load(find.Key); ok {
|
|
||||||
return cache.(*WorkspaceSetting), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listWorkspaceSettings(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceSetting := list[0]
|
|
||||||
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
|
||||||
return workspaceSetting, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceSetting) ([]*WorkspaceSetting, error) {
|
|
||||||
where, args := []string{"1 = 1"}, []any{}
|
where, args := []string{"1 = 1"}, []any{}
|
||||||
|
|
||||||
if find.Key != "" {
|
if find.Key != "" {
|
||||||
where, args = append(where, "key = ?"), append(args, find.Key)
|
where, args = append(where, "key = ?"), append(args, find.Key)
|
||||||
}
|
}
|
||||||
@ -122,7 +60,7 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
|||||||
value
|
value
|
||||||
FROM workspace_setting
|
FROM workspace_setting
|
||||||
WHERE ` + strings.Join(where, " AND ")
|
WHERE ` + strings.Join(where, " AND ")
|
||||||
rows, err := tx.QueryContext(ctx, query, args...)
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -146,5 +84,30 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, workspaceSetting := range list {
|
||||||
|
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
||||||
|
}
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetWorkspaceSetting(ctx context.Context, find *FindWorkspaceSetting) (*WorkspaceSetting, error) {
|
||||||
|
if find.Key != "" {
|
||||||
|
if cache, ok := s.workspaceSettingCache.Load(find.Key); ok {
|
||||||
|
return cache.(*WorkspaceSetting), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListWorkspaceSettings(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceSetting := list[0]
|
||||||
|
s.workspaceSettingCache.Store(workspaceSetting.Key, workspaceSetting)
|
||||||
|
return workspaceSetting, nil
|
||||||
|
}
|
||||||
|
@ -14,12 +14,13 @@ func TestShortcutStore(t *testing.T) {
|
|||||||
user, err := createTestingAdminUser(ctx, ts)
|
user, err := createTestingAdminUser(ctx, ts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
|
shortcut, err := ts.CreateShortcut(ctx, &store.Shortcut{
|
||||||
CreatorID: user.ID,
|
CreatorID: user.ID,
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Link: "https://test.link",
|
Link: "https://test.link",
|
||||||
Description: "A test shortcut",
|
Description: "A test shortcut",
|
||||||
Visibility: store.VisibilityPrivate,
|
Visibility: store.VisibilityPrivate,
|
||||||
Tag: "test link",
|
Tag: "test link",
|
||||||
|
OpenGraphMetadata: &store.OpenGraphMetadata{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
"@mui/joy": "5.0.0-alpha.84",
|
"@mui/joy": "5.0.0-alpha.84",
|
||||||
"@reduxjs/toolkit": "^1.8.1",
|
"@reduxjs/toolkit": "^1.8.1",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"copy-to-clipboard": "^3.3.2",
|
"copy-to-clipboard": "^3.3.2",
|
||||||
"dayjs": "^1.11.3",
|
"dayjs": "^1.11.3",
|
||||||
"i18next": "^23.2.3",
|
"i18next": "^23.2.3",
|
||||||
@ -32,8 +33,8 @@
|
|||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||||
"@typescript-eslint/parser": "^5.6.0",
|
"@typescript-eslint/parser": "^6.2.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"eslint": "^8.4.1",
|
"eslint": "^8.4.1",
|
||||||
|
272
web/pnpm-lock.yaml
generated
272
web/pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ dependencies:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^0.27.2
|
specifier: ^0.27.2
|
||||||
version: 0.27.2
|
version: 0.27.2
|
||||||
|
classnames:
|
||||||
|
specifier: ^2.3.2
|
||||||
|
version: 2.3.2
|
||||||
copy-to-clipboard:
|
copy-to-clipboard:
|
||||||
specifier: ^3.3.2
|
specifier: ^3.3.2
|
||||||
version: 3.3.2
|
version: 3.3.2
|
||||||
@ -77,11 +80,11 @@ devDependencies:
|
|||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.5
|
version: 18.2.5
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^5.6.0
|
specifier: ^6.2.0
|
||||||
version: 5.6.0(@typescript-eslint/parser@5.6.0)(eslint@8.4.1)(typescript@5.0.4)
|
version: 6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.4.1)(typescript@5.0.4)
|
||||||
'@typescript-eslint/parser':
|
'@typescript-eslint/parser':
|
||||||
specifier: ^5.6.0
|
specifier: ^6.2.0
|
||||||
version: 5.6.0(eslint@8.4.1)(typescript@5.0.4)
|
version: 6.2.0(eslint@8.4.1)(typescript@5.0.4)
|
||||||
'@vitejs/plugin-react-swc':
|
'@vitejs/plugin-react-swc':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0(vite@4.0.0)
|
version: 3.0.0(vite@4.0.0)
|
||||||
@ -483,6 +486,21 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/@eslint-community/eslint-utils@4.4.0(eslint@8.4.1):
|
||||||
|
resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==}
|
||||||
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||||
|
dependencies:
|
||||||
|
eslint: 8.4.1
|
||||||
|
eslint-visitor-keys: 3.4.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@eslint-community/regexpp@4.6.2:
|
||||||
|
resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==}
|
||||||
|
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@eslint/eslintrc@1.4.1:
|
/@eslint/eslintrc@1.4.1:
|
||||||
resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==}
|
resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@ -916,113 +934,143 @@ packages:
|
|||||||
/@types/scheduler@0.16.3:
|
/@types/scheduler@0.16.3:
|
||||||
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
|
||||||
|
|
||||||
|
/@types/semver@7.5.0:
|
||||||
|
resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/use-sync-external-store@0.0.3:
|
/@types/use-sync-external-store@0.0.3:
|
||||||
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@typescript-eslint/eslint-plugin@5.6.0(@typescript-eslint/parser@5.6.0)(eslint@8.4.1)(typescript@5.0.4):
|
/@typescript-eslint/eslint-plugin@6.2.0(@typescript-eslint/parser@6.2.0)(eslint@8.4.1)(typescript@5.0.4):
|
||||||
resolution: {integrity: sha512-MIbeMy5qfLqtgs1hWd088k1hOuRsN9JrHUPwVVKCD99EOUqScd7SrwoZl4Gso05EAP9w1kvLWUVGJOVpRPkDPA==}
|
resolution: {integrity: sha512-rClGrMuyS/3j0ETa1Ui7s6GkLhfZGKZL3ZrChLeAiACBE/tRc1wq8SNZESUuluxhLj9FkUefRs2l6bCIArWBiQ==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@typescript-eslint/parser': ^5.0.0
|
'@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha
|
||||||
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
|
eslint: ^7.0.0 || ^8.0.0
|
||||||
typescript: '*'
|
typescript: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/experimental-utils': 5.6.0(eslint@8.4.1)(typescript@5.0.4)
|
'@eslint-community/regexpp': 4.6.2
|
||||||
'@typescript-eslint/parser': 5.6.0(eslint@8.4.1)(typescript@5.0.4)
|
'@typescript-eslint/parser': 6.2.0(eslint@8.4.1)(typescript@5.0.4)
|
||||||
'@typescript-eslint/scope-manager': 5.6.0
|
'@typescript-eslint/scope-manager': 6.2.0
|
||||||
|
'@typescript-eslint/type-utils': 6.2.0(eslint@8.4.1)(typescript@5.0.4)
|
||||||
|
'@typescript-eslint/utils': 6.2.0(eslint@8.4.1)(typescript@5.0.4)
|
||||||
|
'@typescript-eslint/visitor-keys': 6.2.0
|
||||||
debug: 4.3.4
|
debug: 4.3.4
|
||||||
eslint: 8.4.1
|
eslint: 8.4.1
|
||||||
functional-red-black-tree: 1.0.1
|
graphemer: 1.4.0
|
||||||
ignore: 5.2.4
|
ignore: 5.2.4
|
||||||
regexpp: 3.2.0
|
natural-compare: 1.4.0
|
||||||
semver: 7.5.1
|
natural-compare-lite: 1.4.0
|
||||||
tsutils: 3.21.0(typescript@5.0.4)
|
semver: 7.5.4
|
||||||
|
ts-api-utils: 1.0.1(typescript@5.0.4)
|
||||||
typescript: 5.0.4
|
typescript: 5.0.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@typescript-eslint/experimental-utils@5.6.0(eslint@8.4.1)(typescript@5.0.4):
|
/@typescript-eslint/parser@6.2.0(eslint@8.4.1)(typescript@5.0.4):
|
||||||
resolution: {integrity: sha512-VDoRf3Qj7+W3sS/ZBXZh3LBzp0snDLEgvp6qj0vOAIiAPM07bd5ojQ3CTzF/QFl5AKh7Bh1ycgj6lFBJHUt/DA==}
|
resolution: {integrity: sha512-igVYOqtiK/UsvKAmmloQAruAdUHihsOCvplJpplPZ+3h4aDkC/UKZZNKgB6h93ayuYLuEymU3h8nF1xMRbh37g==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '*'
|
eslint: ^7.0.0 || ^8.0.0
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/json-schema': 7.0.12
|
'@typescript-eslint/scope-manager': 6.2.0
|
||||||
'@typescript-eslint/scope-manager': 5.6.0
|
'@typescript-eslint/types': 6.2.0
|
||||||
'@typescript-eslint/types': 5.6.0
|
'@typescript-eslint/typescript-estree': 6.2.0(typescript@5.0.4)
|
||||||
'@typescript-eslint/typescript-estree': 5.6.0(typescript@5.0.4)
|
'@typescript-eslint/visitor-keys': 6.2.0
|
||||||
|
debug: 4.3.4
|
||||||
eslint: 8.4.1
|
eslint: 8.4.1
|
||||||
eslint-scope: 5.1.1
|
typescript: 5.0.4
|
||||||
eslint-utils: 3.0.0(eslint@8.4.1)
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@typescript-eslint/scope-manager@6.2.0:
|
||||||
|
resolution: {integrity: sha512-1ZMNVgm5nnHURU8ZSJ3snsHzpFeNK84rdZjluEVBGNu7jDymfqceB3kdIZ6A4xCfEFFhRIB6rF8q/JIqJd2R0Q==}
|
||||||
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
|
dependencies:
|
||||||
|
'@typescript-eslint/types': 6.2.0
|
||||||
|
'@typescript-eslint/visitor-keys': 6.2.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@typescript-eslint/type-utils@6.2.0(eslint@8.4.1)(typescript@5.0.4):
|
||||||
|
resolution: {integrity: sha512-DnGZuNU2JN3AYwddYIqrVkYW0uUQdv0AY+kz2M25euVNlujcN2u+rJgfJsBFlUEzBB6OQkUqSZPyuTLf2bP5mw==}
|
||||||
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: ^7.0.0 || ^8.0.0
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@typescript-eslint/typescript-estree': 6.2.0(typescript@5.0.4)
|
||||||
|
'@typescript-eslint/utils': 6.2.0(eslint@8.4.1)(typescript@5.0.4)
|
||||||
|
debug: 4.3.4
|
||||||
|
eslint: 8.4.1
|
||||||
|
ts-api-utils: 1.0.1(typescript@5.0.4)
|
||||||
|
typescript: 5.0.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@typescript-eslint/types@6.2.0:
|
||||||
|
resolution: {integrity: sha512-1nRRaDlp/XYJQLvkQJG5F3uBTno5SHPT7XVcJ5n1/k2WfNI28nJsvLakxwZRNY5spuatEKO7d5nZWsQpkqXwBA==}
|
||||||
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@typescript-eslint/typescript-estree@6.2.0(typescript@5.0.4):
|
||||||
|
resolution: {integrity: sha512-Mts6+3HQMSM+LZCglsc2yMIny37IhUgp1Qe8yJUYVyO6rHP7/vN0vajKu3JvHCBIy8TSiKddJ/Zwu80jhnGj1w==}
|
||||||
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@typescript-eslint/types': 6.2.0
|
||||||
|
'@typescript-eslint/visitor-keys': 6.2.0
|
||||||
|
debug: 4.3.4
|
||||||
|
globby: 11.1.0
|
||||||
|
is-glob: 4.0.3
|
||||||
|
semver: 7.5.4
|
||||||
|
ts-api-utils: 1.0.1(typescript@5.0.4)
|
||||||
|
typescript: 5.0.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@typescript-eslint/utils@6.2.0(eslint@8.4.1)(typescript@5.0.4):
|
||||||
|
resolution: {integrity: sha512-RCFrC1lXiX1qEZN8LmLrxYRhOkElEsPKTVSNout8DMzf8PeWoQG7Rxz2SadpJa3VSh5oYKGwt7j7X/VRg+Y3OQ==}
|
||||||
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: ^7.0.0 || ^8.0.0
|
||||||
|
dependencies:
|
||||||
|
'@eslint-community/eslint-utils': 4.4.0(eslint@8.4.1)
|
||||||
|
'@types/json-schema': 7.0.12
|
||||||
|
'@types/semver': 7.5.0
|
||||||
|
'@typescript-eslint/scope-manager': 6.2.0
|
||||||
|
'@typescript-eslint/types': 6.2.0
|
||||||
|
'@typescript-eslint/typescript-estree': 6.2.0(typescript@5.0.4)
|
||||||
|
eslint: 8.4.1
|
||||||
|
semver: 7.5.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@typescript-eslint/parser@5.6.0(eslint@8.4.1)(typescript@5.0.4):
|
/@typescript-eslint/visitor-keys@6.2.0:
|
||||||
resolution: {integrity: sha512-YVK49NgdUPQ8SpCZaOpiq1kLkYRPMv9U5gcMrywzI8brtwZjr/tG3sZpuHyODt76W/A0SufNjYt9ZOgrC4tLIQ==}
|
resolution: {integrity: sha512-QbaYUQVKKo9bgCzpjz45llCfwakyoxHetIy8CAvYCtd16Zu1KrpzNHofwF8kGkpPOxZB2o6kz+0nqH8ZkIzuoQ==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^16.0.0 || >=18.0.0}
|
||||||
peerDependencies:
|
|
||||||
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
|
|
||||||
typescript: '*'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
typescript:
|
|
||||||
optional: true
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 5.6.0
|
'@typescript-eslint/types': 6.2.0
|
||||||
'@typescript-eslint/types': 5.6.0
|
|
||||||
'@typescript-eslint/typescript-estree': 5.6.0(typescript@5.0.4)
|
|
||||||
debug: 4.3.4
|
|
||||||
eslint: 8.4.1
|
|
||||||
typescript: 5.0.4
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@typescript-eslint/scope-manager@5.6.0:
|
|
||||||
resolution: {integrity: sha512-1U1G77Hw2jsGWVsO2w6eVCbOg0HZ5WxL/cozVSTfqnL/eB9muhb8THsP0G3w+BB5xAHv9KptwdfYFAUfzcIh4A==}
|
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
|
||||||
dependencies:
|
|
||||||
'@typescript-eslint/types': 5.6.0
|
|
||||||
'@typescript-eslint/visitor-keys': 5.6.0
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@typescript-eslint/types@5.6.0:
|
|
||||||
resolution: {integrity: sha512-OIZffked7mXv4mXzWU5MgAEbCf9ecNJBKi+Si6/I9PpTaj+cf2x58h2oHW5/P/yTnPkKaayfjhLvx+crnl5ubA==}
|
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@typescript-eslint/typescript-estree@5.6.0(typescript@5.0.4):
|
|
||||||
resolution: {integrity: sha512-92vK5tQaE81rK7fOmuWMrSQtK1IMonESR+RJR2Tlc7w4o0MeEdjgidY/uO2Gobh7z4Q1hhS94Cr7r021fMVEeA==}
|
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
|
||||||
peerDependencies:
|
|
||||||
typescript: '*'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
typescript:
|
|
||||||
optional: true
|
|
||||||
dependencies:
|
|
||||||
'@typescript-eslint/types': 5.6.0
|
|
||||||
'@typescript-eslint/visitor-keys': 5.6.0
|
|
||||||
debug: 4.3.4
|
|
||||||
globby: 11.1.0
|
|
||||||
is-glob: 4.0.3
|
|
||||||
semver: 7.5.1
|
|
||||||
tsutils: 3.21.0(typescript@5.0.4)
|
|
||||||
typescript: 5.0.4
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@typescript-eslint/visitor-keys@5.6.0:
|
|
||||||
resolution: {integrity: sha512-1p7hDp5cpRFUyE3+lvA74egs+RWSgumrBpzBCDzfTFv0aQ7lIeay80yU0hIxgAhwQ6PcasW35kaOCyDOv6O/Ng==}
|
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
|
||||||
dependencies:
|
|
||||||
'@typescript-eslint/types': 5.6.0
|
|
||||||
eslint-visitor-keys: 3.4.1
|
eslint-visitor-keys: 3.4.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
@ -1263,6 +1311,10 @@ packages:
|
|||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/classnames@2.3.2:
|
||||||
|
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/clsx@1.2.1:
|
/clsx@1.2.1:
|
||||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -1579,14 +1631,6 @@ packages:
|
|||||||
string.prototype.matchall: 4.0.8
|
string.prototype.matchall: 4.0.8
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/eslint-scope@5.1.1:
|
|
||||||
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
dependencies:
|
|
||||||
esrecurse: 4.3.0
|
|
||||||
estraverse: 4.3.0
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/eslint-scope@7.2.0:
|
/eslint-scope@7.2.0:
|
||||||
resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==}
|
resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@ -1685,11 +1729,6 @@ packages:
|
|||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/estraverse@4.3.0:
|
|
||||||
resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==}
|
|
||||||
engines: {node: '>=4.0'}
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/estraverse@5.3.0:
|
/estraverse@5.3.0:
|
||||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@ -1911,6 +1950,10 @@ packages:
|
|||||||
get-intrinsic: 1.2.1
|
get-intrinsic: 1.2.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/graphemer@1.4.0:
|
||||||
|
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/has-bigints@1.0.2:
|
/has-bigints@1.0.2:
|
||||||
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
|
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -2267,6 +2310,10 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
/natural-compare-lite@1.4.0:
|
||||||
|
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/natural-compare@1.4.0:
|
/natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -2763,6 +2810,14 @@ packages:
|
|||||||
lru-cache: 6.0.0
|
lru-cache: 6.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/semver@7.5.4:
|
||||||
|
resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
lru-cache: 6.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/shebang-command@2.0.0:
|
/shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2947,24 +3002,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
|
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/ts-api-utils@1.0.1(typescript@5.0.4):
|
||||||
|
resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==}
|
||||||
|
engines: {node: '>=16.13.0'}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=4.2.0'
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.0.4
|
||||||
|
dev: true
|
||||||
|
|
||||||
/ts-interface-checker@0.1.13:
|
/ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/tslib@1.14.1:
|
|
||||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/tsutils@3.21.0(typescript@5.0.4):
|
|
||||||
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
|
|
||||||
engines: {node: '>= 6'}
|
|
||||||
peerDependencies:
|
|
||||||
typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta'
|
|
||||||
dependencies:
|
|
||||||
tslib: 1.14.1
|
|
||||||
typescript: 5.0.4
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/type-check@0.4.0:
|
/type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { globalService } from "./services";
|
import { globalService } from "./services";
|
||||||
import useUserStore from "./stores/v1/user";
|
import useUserStore from "./stores/v1/user";
|
||||||
|
import DemoBanner from "./components/DemoBanner";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
@ -27,7 +28,14 @@ function App() {
|
|||||||
initialState();
|
initialState();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{!loading && <Outlet />}</>;
|
return !loading ? (
|
||||||
|
<>
|
||||||
|
<DemoBanner />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@ -19,12 +19,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-w-full w-80 sm:w-96">
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Slash</span> is a bookmarking and short link service that allows you to save and share links
|
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||||
easily.
|
|
||||||
</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">
|
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -11,7 +11,7 @@ interface Props {
|
|||||||
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcutId, onClose } = props;
|
const { shortcutId, onClose } = props;
|
||||||
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("os");
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||||
@ -32,22 +32,16 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
|||||||
{analytics ? (
|
{analytics ? (
|
||||||
<>
|
<>
|
||||||
<p className="w-full py-1 px-2">Top Sources</p>
|
<p className="w-full py-1 px-2">Top Sources</p>
|
||||||
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
<div className="w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<tr>
|
<span className="py-1 px-2 text-left font-semibold text-sm text-gray-500">Source</span>
|
||||||
<th scope="col" className="py-1 px-2 text-left font-semibold text-sm text-gray-500">
|
<span className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">Visitors</span>
|
||||||
Source
|
</div>
|
||||||
</th>
|
<div className="w-full divide-y divide-gray-200">
|
||||||
<th scope="col" className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">
|
|
||||||
Visitors
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{analytics.referenceData.map((reference) => (
|
{analytics.referenceData.map((reference) => (
|
||||||
<tr key={reference.name}>
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">
|
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900">
|
||||||
{reference.name ? (
|
{reference.name ? (
|
||||||
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||||
{reference.name}
|
{reference.name}
|
||||||
@ -55,28 +49,17 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
|||||||
) : (
|
) : (
|
||||||
"Direct"
|
"Direct"
|
||||||
)}
|
)}
|
||||||
</td>
|
</span>
|
||||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
|
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
|
||||||
<span>Devices</span>
|
<span>Devices</span>
|
||||||
<div>
|
<div>
|
||||||
<button
|
|
||||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
|
||||||
selectedDeviceTab === "os"
|
|
||||||
? "border-blue-600 text-blue-600"
|
|
||||||
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedDeviceTab("os")}
|
|
||||||
>
|
|
||||||
OS
|
|
||||||
</button>
|
|
||||||
<span className="text-gray-200 font-mono mx-1">/</span>
|
|
||||||
<button
|
<button
|
||||||
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
selectedDeviceTab === "browser"
|
selectedDeviceTab === "browser"
|
||||||
@ -87,56 +70,60 @@ const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
|||||||
>
|
>
|
||||||
Browser
|
Browser
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-gray-200 font-mono mx-1">/</span>
|
||||||
|
<button
|
||||||
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
|
selectedDeviceTab === "os"
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDeviceTab("os")}
|
||||||
|
>
|
||||||
|
OS
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
{selectedDeviceTab === "os" ? (
|
{selectedDeviceTab === "browser" ? (
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
<div className="w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<tr>
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Browsers</span>
|
||||||
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
|
||||||
Operating system
|
</div>
|
||||||
</th>
|
<div className="w-full divide-y divide-gray-200">
|
||||||
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
|
|
||||||
Visitors
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{analytics.deviceData.map((reference) => (
|
|
||||||
<tr key={reference.name}>
|
|
||||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
|
|
||||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="py-2 px-2 text-left text-sm font-semibold text-gray-500">
|
|
||||||
Browsers
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">
|
|
||||||
Visitors
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{analytics.browserData.map((reference) => (
|
{analytics.browserData.map((reference) => (
|
||||||
<tr key={reference.name}>
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
<td className="whitespace-nowrap py-2 px-2 text-sm text-gray-900">{reference.name || "Unknown"}</td>
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{reference.name || "Unknown"}</span>
|
||||||
<td className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right">{reference.count}</td>
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full divide-y divide-gray-300">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-2 px-2 text-left text-sm font-semibold text-gray-500">Operating system</span>
|
||||||
|
<span className="py-2 pr-2 text-right text-sm font-semibold text-gray-500">Visitors</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{analytics.deviceData.map((device) => (
|
||||||
|
<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 pr-2 text-sm text-gray-500 text-right shrink-0">{device.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||||
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
|
loading
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||||
|
import classnames from "classnames";
|
||||||
import { isUndefined } from "lodash-es";
|
import { isUndefined } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -29,26 +30,34 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
description: "",
|
description: "",
|
||||||
visibility: "PRIVATE",
|
visibility: "PRIVATE",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
openGraphMetadata: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const [showAdditionalFields, setShowAdditionalFields] = useState<boolean>(false);
|
||||||
|
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||||
const [tag, setTag] = useState<string>("");
|
const [tag, setTag] = useState<string>("");
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
const isCreating = isUndefined(shortcutId);
|
const isCreating = isUndefined(shortcutId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shortcutId) {
|
if (shortcutId) {
|
||||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||||
if (shortcutTemp) {
|
if (shortcut) {
|
||||||
setState({
|
setState({
|
||||||
...state,
|
...state,
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
name: shortcutTemp.name,
|
name: shortcut.name,
|
||||||
link: shortcutTemp.link,
|
link: shortcut.link,
|
||||||
description: shortcutTemp.description,
|
description: shortcut.description,
|
||||||
visibility: shortcutTemp.visibility,
|
visibility: shortcut.visibility,
|
||||||
|
openGraphMetadata: shortcut.openGraphMetadata,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setTag(shortcutTemp.tags.join(" "));
|
setTag(shortcut.tags.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [shortcutId]);
|
}, [shortcutId]);
|
||||||
@ -76,6 +85,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
visibility: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
@ -89,10 +106,35 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
setTag(text);
|
setTag(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setPartialState({
|
setPartialState({
|
||||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
visibility: e.target.value,
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
image: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
title: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
description: e.target.value,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -112,6 +154,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
description: state.shortcutCreate.description,
|
description: state.shortcutCreate.description,
|
||||||
visibility: state.shortcutCreate.visibility,
|
visibility: state.shortcutCreate.visibility,
|
||||||
tags: tag.split(" "),
|
tags: tag.split(" "),
|
||||||
|
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await shortcutService.createShortcut({
|
await shortcutService.createShortcut({
|
||||||
@ -140,11 +183,9 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
Name <span className="text-red-600">*</span>
|
|
||||||
</span>
|
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -156,35 +197,21 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</div>
|
</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>
|
||||||
Link <span className="text-red-600">*</span>
|
|
||||||
</span>
|
|
||||||
<Input
|
<Input
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="The full URL of the page you want to get to"
|
placeholder="https://github.com/boojack/slash"
|
||||||
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">Description</span>
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
type="text"
|
|
||||||
placeholder="Something to describe the link"
|
|
||||||
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="Separated by spaces" value={tag} onChange={handleTagsInputChange} />
|
<Input className="w-full" type="text" placeholder="github slash" value={tag} onChange={handleTagsInputChange} />
|
||||||
</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">Visibility</span>
|
||||||
Visibility <span className="text-red-600">*</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.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
<RadioGroup orientation="horizontal" value={state.shortcutCreate.visibility} onChange={handleVisibilityInputChange}>
|
||||||
{visibilities.map((visibility) => (
|
{visibilities.map((visibility) => (
|
||||||
@ -196,6 +223,90 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Divider className="text-gray-500">Optional</Divider>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100",
|
||||||
|
showAdditionalFields ? "bg-gray-100 border-b" : ""
|
||||||
|
)}
|
||||||
|
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">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">
|
||||||
|
<div
|
||||||
|
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
|
||||||
|
showOpenGraphMetadata ? "bg-gray-100 border-b" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||||
|
>
|
||||||
|
<span className="text-sm flex flex-row justify-start items-center">
|
||||||
|
Social media metadata
|
||||||
|
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
|
||||||
|
</span>
|
||||||
|
<button className="w-7 h-7 p-1 rounded-md">
|
||||||
|
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showOpenGraphMetadata && (
|
||||||
|
<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">Image URL</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://the.link.to/the/image.png"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.image}
|
||||||
|
onChange={handleOpenGraphMetadataImageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.title}
|
||||||
|
onChange={handleOpenGraphMetadataTitleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Description</span>
|
||||||
|
<Textarea
|
||||||
|
className="w-full"
|
||||||
|
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||||
|
size="sm"
|
||||||
|
maxRows={3}
|
||||||
|
value={state.shortcutCreate.openGraphMetadata.description}
|
||||||
|
onChange={handleOpenGraphMetadataDescriptionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
<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}>
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
|
@ -12,9 +12,9 @@ const DemoBanner: React.FC = () => {
|
|||||||
if (!shouldShow) return null;
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-10 flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||||
<div className="w-full max-w-4xl px-4 flex flex-row justify-between items-center gap-x-3">
|
<div className="w-full max-w-4xl px-4 flex flex-row justify-between items-center gap-x-3">
|
||||||
<span>✨A bookmarking and url shortener, save and share your links very easily.✨</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/boojack/slash#deploy-with-docker-in-seconds"
|
||||||
|
@ -21,7 +21,8 @@ const FilterView = () => {
|
|||||||
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
className="ml-2 px-2 py-1 flex flex-row justify-center items-center bg-gray-100 rounded-full text-gray-500 text-sm hover:line-through"
|
||||||
onClick={() => viewStore.setFilter({ tag: undefined })}
|
onClick={() => viewStore.setFilter({ tag: undefined })}
|
||||||
>
|
>
|
||||||
<Icon.Tag className="w-4 h-auto mr-1" />#{filter.tag}
|
<Icon.Tag className="w-4 h-auto mr-1" />
|
||||||
|
<span className="max-w-[8rem] truncate">#{filter.tag}</span>
|
||||||
<Icon.X className="w-4 h-auto ml-1" />
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
62
web/src/components/Navigator.tsx
Normal file
62
web/src/components/Navigator.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
import { useAppSelector } from "../stores";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const Navigator = () => {
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
|
const tags = shortcutList.map((shortcut) => shortcut.tags).flat();
|
||||||
|
const currentTab = viewStore.filter.tab || `tab:all`;
|
||||||
|
const sortedTagMap = sortTags(tags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||||
|
currentTab === "tab:all" ? "!bg-gray-600 text-white shadow" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
|
||||||
|
>
|
||||||
|
<Icon.CircleSlash className="w-4 h-auto mr-1" />
|
||||||
|
<span className="font-normal">All</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||||
|
currentTab === "tab:mine" ? "!bg-gray-600 text-white shadow" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
|
||||||
|
>
|
||||||
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
|
<span className="font-normal">Mine</span>
|
||||||
|
</button>
|
||||||
|
{Array.from(sortedTagMap.keys()).map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-row justify-center items-center px-2 leading-7 text-sm rounded-md hover:bg-gray-200",
|
||||||
|
currentTab === `tag:${tag}` ? "!bg-gray-600 text-white shadow" : ""
|
||||||
|
)}
|
||||||
|
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
|
||||||
|
>
|
||||||
|
<Icon.Hash className="w-4 h-auto mr-0.5" />
|
||||||
|
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortTags = (tags: string[]): Map<string, number> => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const count = map.get(tag) || 0;
|
||||||
|
map.set(tag, count + 1);
|
||||||
|
}
|
||||||
|
const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1]));
|
||||||
|
return sortedMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigator;
|
@ -31,6 +31,7 @@ const ShortcutView = (props: Props) => {
|
|||||||
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
||||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
const compactStyle = viewStore.layout === "grid";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||||
@ -58,7 +59,7 @@ const ShortcutView = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col justify-start items-start border px-4 py-3 mb-2 rounded-lg hover:shadow">
|
<div className="w-full flex flex-col justify-start items-start border px-4 py-3 rounded-lg hover:shadow">
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<div className="group flex flex-row justify-start items-center pr-2 mr-1 shrink-0">
|
<div className="group flex flex-row justify-start items-center pr-2 mr-1 shrink-0">
|
||||||
<div className="w-6 h-6 mr-1 flex justify-center items-center overflow-clip">
|
<div className="w-6 h-6 mr-1 flex justify-center items-center overflow-clip">
|
||||||
@ -74,14 +75,14 @@ const ShortcutView = (props: Props) => {
|
|||||||
href={shortcutLink}
|
href={shortcutLink}
|
||||||
>
|
>
|
||||||
<span className="text-gray-400">s/</span>
|
<span className="text-gray-400">s/</span>
|
||||||
{shortcut.name}
|
<span className="max-w-[14rem] truncate">{shortcut.name}</span>
|
||||||
<span className="hidden group-hover:block ml-1 cursor-pointer">
|
<span className="hidden group-hover:block ml-1 cursor-pointer">
|
||||||
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||||
<button
|
<button
|
||||||
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
|
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||||
onClick={() => handleCopyButtonClick()}
|
onClick={() => handleCopyButtonClick()}
|
||||||
>
|
>
|
||||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
@ -89,7 +90,7 @@ const ShortcutView = (props: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
||||||
<button
|
<button
|
||||||
className="hidden group-hover:block ml-1 w-6 h-6 cursor-pointer rounded-full text-gray-500 hover:bg-gray-100 hover:shadow hover:text-blue-600"
|
className="hidden group-hover:block ml-1 w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||||
onClick={() => setShowQRCodeDialog(true)}
|
onClick={() => setShowQRCodeDialog(true)}
|
||||||
>
|
>
|
||||||
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
||||||
@ -128,28 +129,26 @@ const ShortcutView = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shortcut.description && <p className="mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
|
{shortcut.description && !compactStyle && <p className="w-full break-all mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
|
||||||
{shortcut.tags.length > 0 && (
|
<div className="mt-2 flex flex-row justify-start items-start flex-wrap gap-2">
|
||||||
<div className="mt-2 ml-1 flex flex-row justify-start items-start gap-2">
|
{shortcut.tags.map((tag) => {
|
||||||
<Icon.Tag className="text-gray-400 w-4 h-auto" />
|
return (
|
||||||
{shortcut.tags.map((tag) => {
|
<span
|
||||||
return (
|
key={tag}
|
||||||
<span
|
className="max-w-[8rem] truncate text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
||||||
key={tag}
|
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||||
className="text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
>
|
||||||
onClick={() => viewStore.setFilter({ tag: tag })}
|
#{tag}
|
||||||
>
|
</span>
|
||||||
#{tag}
|
);
|
||||||
</span>
|
})}
|
||||||
);
|
{shortcut.tags.length === 0 && <span className="text-gray-400 text-sm font-mono leading-4 italic">No tags</span>}
|
||||||
})}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="w-full flex mt-2 gap-2">
|
<div className="w-full flex mt-2 gap-2">
|
||||||
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||||
<Icon.User className="w-4 h-auto mr-1" />
|
<Icon.User className="w-4 h-auto mr-1" />
|
||||||
{shortcut.creator.nickname}
|
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.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.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||||
|
@ -1,18 +1,27 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import CreateShortcutDialog from "./CreateShortcutDialog";
|
import CreateShortcutDialog from "./CreateShortcutDialog";
|
||||||
import ShortcutView from "./ShortcutView";
|
import ShortcutView from "./ShortcutView";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcutList: Shortcut[];
|
shortcutList: Shortcut[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutListView: React.FC<Props> = (props: Props) => {
|
const ShortcutsContainer: React.FC<Props> = (props: Props) => {
|
||||||
const { shortcutList } = props;
|
const { shortcutList } = props;
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const layout = viewStore.layout || "list";
|
||||||
const [editingShortcutId, setEditingShortcutId] = useState<ShortcutId | undefined>();
|
const [editingShortcutId, setEditingShortcutId] = useState<ShortcutId | undefined>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"w-full flex flex-col justify-start items-start gap-y-2",
|
||||||
|
layout === "grid" && "sm:grid sm:grid-cols-2 sm:gap-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{shortcutList.map((shortcut) => {
|
{shortcutList.map((shortcut) => {
|
||||||
return <ShortcutView key={shortcut.id} shortcut={shortcut} handleEdit={() => setEditingShortcutId(shortcut.id)} />;
|
return <ShortcutView key={shortcut.id} shortcut={shortcut} handleEdit={() => setEditingShortcutId(shortcut.id)} />;
|
||||||
})}
|
})}
|
||||||
@ -31,4 +40,4 @@ const ShortcutListView: React.FC<Props> = (props: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortcutListView;
|
export default ShortcutsContainer;
|
63
web/src/components/ViewSetting.tsx
Normal file
63
web/src/components/ViewSetting.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Select, Option, Button } from "@mui/joy";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Dropdown from "./common/Dropdown";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const ViewSetting = () => {
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const order = viewStore.getOrder();
|
||||||
|
const { field, direction } = order;
|
||||||
|
const layout = viewStore.layout || "list";
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
viewStore.setOrder({ field: "name", direction: "asc" });
|
||||||
|
toast.success("Order reset");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="p-1">
|
||||||
|
<Icon.ListFilter className="w-5 h-auto text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
actionsClassName="right-10 translate-x-full"
|
||||||
|
actions={
|
||||||
|
<div className="w-52 p-2 pt-0 gap-2 flex flex-col justify-start items-start" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center mt-1">
|
||||||
|
<span className="text-sm font-medium">View order</span>
|
||||||
|
<Button size="sm" variant="plain" color="neutral" onClick={handleReset}>
|
||||||
|
<Icon.RefreshCw className="w-4 h-auto text-gray-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-sm shrink-0 mr-2">Order by</span>
|
||||||
|
<Select size="sm" value={field} onChange={(_, value) => viewStore.setOrder({ field: value as any })}>
|
||||||
|
<Option value={"name"}>Name</Option>
|
||||||
|
<Option value={"updatedTs"}>CreatedAt</Option>
|
||||||
|
<Option value={"createdTs"}>UpdatedAt</Option>
|
||||||
|
<Option value={"view"}>Visits</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-sm shrink-0 mr-2">Direction</span>
|
||||||
|
<Select size="sm" value={direction} onChange={(_, value) => viewStore.setOrder({ direction: value as any })}>
|
||||||
|
<Option value={"asc"}>ASC</Option>
|
||||||
|
<Option value={"desc"}>DESC</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-sm shrink-0 mr-2">Layout</span>
|
||||||
|
<Select size="sm" value={layout} onChange={(_, value) => viewStore.setLayout(value as any)}>
|
||||||
|
<Option value={"list"}>List</Option>
|
||||||
|
<Option value={"grid"}>Grid</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ViewSetting;
|
@ -21,10 +21,15 @@ const Dropdown: React.FC<Props> = (props: Props) => {
|
|||||||
toggleDropdownStatus(false);
|
toggleDropdownStatus(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("click", handleClickOutside, {
|
window.addEventListener("click", handleClickOutside, {
|
||||||
capture: true,
|
capture: true,
|
||||||
once: true,
|
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", handleClickOutside, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [dropdownStatus]);
|
}, [dropdownStatus]);
|
||||||
|
|
||||||
|
@ -10,3 +10,18 @@ html,
|
|||||||
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
"WenQuanYi Micro Hei", sans-serif, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
"Noto Color Emoji";
|
"Noto Color Emoji";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@variants responsive {
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
import { Outlet, useNavigate } from "react-router-dom";
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import DemoBanner from "../components/DemoBanner";
|
|
||||||
|
|
||||||
const Root: React.FC = () => {
|
const Root: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -20,7 +19,6 @@ const Root: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<div className="w-full h-full flex flex-col justify-start items-start">
|
<div className="w-full h-full flex flex-col justify-start items-start">
|
||||||
<DemoBanner />
|
|
||||||
<Header />
|
<Header />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,42 +1,21 @@
|
|||||||
import { Button, Tab, TabList, Tabs } from "@mui/joy";
|
import { Button, Input } from "@mui/joy";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
import { useAppSelector } from "../stores";
|
import { useAppSelector } from "../stores";
|
||||||
import useViewStore, { Filter } from "../stores/v1/view";
|
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import ShortcutListView from "../components/ShortcutListView";
|
import ShortcutsContainer from "../components/ShortcutsContainer";
|
||||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||||
import FilterView from "../components/FilterView";
|
import FilterView from "../components/FilterView";
|
||||||
|
import ViewSetting from "../components/ViewSetting";
|
||||||
|
import Navigator from "../components/Navigator";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
showCreateShortcutDialog: boolean;
|
showCreateShortcutDialog: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
|
|
||||||
const { tag, mineOnly, visibility } = filter;
|
|
||||||
const filteredShortcutList = shortcutList.filter((shortcut) => {
|
|
||||||
if (tag) {
|
|
||||||
if (!shortcut.tags.includes(tag)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mineOnly) {
|
|
||||||
if (shortcut.creatorId !== currentUser.id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (visibility) {
|
|
||||||
if (shortcut.visibility !== visibility) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return filteredShortcutList;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const loadingState = useLoading();
|
const loadingState = useLoading();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
@ -47,6 +26,7 @@ const Home: React.FC = () => {
|
|||||||
});
|
});
|
||||||
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
||||||
@ -63,44 +43,44 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start">
|
<div className="mx-auto max-w-4xl w-full px-3 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-4">
|
<Navigator />
|
||||||
<span className="font-mono text-gray-400 mr-2">Shortcuts</span>
|
|
||||||
</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 className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
<Tabs
|
<Input
|
||||||
value={filter.mineOnly ? "PRIVATE" : "ALL"}
|
className="w-32 mr-2"
|
||||||
|
type="text"
|
||||||
size="sm"
|
size="sm"
|
||||||
onChange={(_, value) => viewStore.setFilter({ mineOnly: value !== "ALL" })}
|
placeholder="Search"
|
||||||
>
|
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||||
<TabList>
|
endDecorator={
|
||||||
<Tab value={"ALL"}>All</Tab>
|
filter.search && <Icon.X className="w-4 h-auto cursor-pointer" onClick={() => viewStore.setFilter({ search: "" })} />
|
||||||
<Tab value={"PRIVATE"}>Mine</Tab>
|
}
|
||||||
</TabList>
|
value={filter.search}
|
||||||
</Tabs>
|
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||||
|
/>
|
||||||
|
<ViewSetting />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-row justify-end items-center">
|
||||||
<Button className="shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||||
<Icon.Plus className="w-5 h-auto" /> New
|
<Icon.Plus className="w-5 h-auto" />
|
||||||
|
<span className="hidden sm:block ml-0.5">Create</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterView />
|
<FilterView />
|
||||||
|
|
||||||
{loadingState.isLoading ? (
|
{loadingState.isLoading ? (
|
||||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
loading
|
loading
|
||||||
</div>
|
</div>
|
||||||
) : filteredShortcutList.length === 0 ? (
|
) : orderedShortcutList.length === 0 ? (
|
||||||
<div className="py-16 w-full flex flex-col justify-center items-center">
|
<div className="py-16 w-full flex flex-col justify-center items-center">
|
||||||
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
||||||
<p className="mt-4">No shortcuts found.</p>
|
<p className="mt-4">No shortcuts found.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ShortcutListView shortcutList={filteredShortcutList} />
|
<ShortcutsContainer shortcutList={orderedShortcutList} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Button, Input } from "@mui/joy";
|
import { Button, Input } from "@mui/joy";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { FormEvent, useEffect, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
@ -29,7 +29,7 @@ const SignIn: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "demo") {
|
if (mode === "demo") {
|
||||||
setEmail("slash@stevenlgtm.com");
|
setEmail("steven@usememos.com");
|
||||||
setPassword("secret");
|
setPassword("secret");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -44,7 +44,8 @@ const SignIn: React.FC = () => {
|
|||||||
setPassword(text);
|
setPassword(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSigninBtnClick = async () => {
|
const handleSigninBtnClick = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
if (actionBtnLoadingState.isLoading) {
|
if (actionBtnLoadingState.isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Button, Input } from "@mui/joy";
|
import { Button, Input } from "@mui/joy";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { FormEvent, useEffect, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
@ -48,7 +48,8 @@ const SignUp: React.FC = () => {
|
|||||||
setPassword(text);
|
setPassword(text);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignupBtnClick = async () => {
|
const handleSignupBtnClick = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
if (actionBtnLoadingState.isLoading) {
|
if (actionBtnLoadingState.isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -2,23 +2,53 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
tab?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
mineOnly?: boolean;
|
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
field: "name" | "createdTs" | "updatedTs" | "view";
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Layout = "grid" | "list";
|
||||||
|
|
||||||
interface ViewState {
|
interface ViewState {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
order: Order;
|
||||||
|
layout: Layout;
|
||||||
setFilter: (filter: Partial<Filter>) => void;
|
setFilter: (filter: Partial<Filter>) => void;
|
||||||
|
getOrder: () => Order;
|
||||||
|
setOrder: (order: Partial<Order>) => void;
|
||||||
|
setLayout: (layout: Layout) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useViewStore = create<ViewState>()(
|
const useViewStore = create<ViewState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
filter: {},
|
filter: {},
|
||||||
|
order: {
|
||||||
|
field: "name",
|
||||||
|
direction: "asc",
|
||||||
|
},
|
||||||
|
layout: "list",
|
||||||
setFilter: (filter: Partial<Filter>) => {
|
setFilter: (filter: Partial<Filter>) => {
|
||||||
set({ filter: { ...get().filter, ...filter } });
|
set({ filter: { ...get().filter, ...filter } });
|
||||||
},
|
},
|
||||||
|
getOrder: () => {
|
||||||
|
return {
|
||||||
|
field: get().order.field || "name",
|
||||||
|
direction: get().order.direction || "asc",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setOrder: (order: Partial<Order>) => {
|
||||||
|
set({ order: { ...get().order, ...order } });
|
||||||
|
},
|
||||||
|
setLayout: (layout: Layout) => {
|
||||||
|
set({ layout });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "view",
|
name: "view",
|
||||||
@ -26,4 +56,61 @@ const useViewStore = create<ViewState>()(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getFilteredShortcutList = (shortcutList: Shortcut[], filter: Filter, currentUser: User) => {
|
||||||
|
const { tab, tag, visibility, search } = filter;
|
||||||
|
const filteredShortcutList = shortcutList.filter((shortcut) => {
|
||||||
|
if (tag) {
|
||||||
|
if (!shortcut.tags.includes(tag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visibility) {
|
||||||
|
if (shortcut.visibility !== visibility) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
if (
|
||||||
|
!shortcut.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
|
!shortcut.description.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
|
!shortcut.tags.some((tag) => tag.toLowerCase().includes(search.toLowerCase())) &&
|
||||||
|
!shortcut.link.toLowerCase().includes(search.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tab) {
|
||||||
|
if (tab === "tab:mine") {
|
||||||
|
return shortcut.creatorId === currentUser.id;
|
||||||
|
} else if (tab.startsWith("tag:")) {
|
||||||
|
const tag = tab.split(":")[1];
|
||||||
|
return shortcut.tags.includes(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return filteredShortcutList;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderedShortcutList = (shortcutList: Shortcut[], order: Order) => {
|
||||||
|
const { field, direction } = {
|
||||||
|
field: order.field || "name",
|
||||||
|
direction: order.direction || "asc",
|
||||||
|
};
|
||||||
|
const orderedShortcutList = shortcutList.sort((a, b) => {
|
||||||
|
if (field === "name") {
|
||||||
|
return direction === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
|
||||||
|
} else if (field === "createdTs") {
|
||||||
|
return direction === "asc" ? a.createdTs - b.createdTs : b.createdTs - a.createdTs;
|
||||||
|
} else if (field === "updatedTs") {
|
||||||
|
return direction === "asc" ? a.updatedTs - b.updatedTs : b.updatedTs - a.updatedTs;
|
||||||
|
} else if (field === "view") {
|
||||||
|
return direction === "asc" ? a.view - b.view : b.view - a.view;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return orderedShortcutList;
|
||||||
|
};
|
||||||
|
|
||||||
export default useViewStore;
|
export default useViewStore;
|
||||||
|
9
web/src/types/modules/shortcut.d.ts
vendored
9
web/src/types/modules/shortcut.d.ts
vendored
@ -2,6 +2,12 @@ type ShortcutId = number;
|
|||||||
|
|
||||||
type Visibility = "PRIVATE" | "WORKSPACE" | "PUBLIC";
|
type Visibility = "PRIVATE" | "WORKSPACE" | "PUBLIC";
|
||||||
|
|
||||||
|
interface OpenGraphMetadata {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Shortcut {
|
interface Shortcut {
|
||||||
id: ShortcutId;
|
id: ShortcutId;
|
||||||
|
|
||||||
@ -16,6 +22,7 @@ interface Shortcut {
|
|||||||
description: string;
|
description: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
openGraphMetadata: OpenGraphMetadata;
|
||||||
view: number;
|
view: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +32,7 @@ interface ShortcutCreate {
|
|||||||
description: string;
|
description: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
openGraphMetadata: OpenGraphMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortcutPatch {
|
interface ShortcutPatch {
|
||||||
@ -35,6 +43,7 @@ interface ShortcutPatch {
|
|||||||
description?: string;
|
description?: string;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
openGraphMetadata?: OpenGraphMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortcutFind {
|
interface ShortcutFind {
|
||||||
|
Reference in New Issue
Block a user