mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 13:12:36 +00:00
Compare commits
53 Commits
Author | SHA1 | Date | |
---|---|---|---|
53c1d8fa91 | |||
b32fdbfc0a | |||
db2aebcf57 | |||
b4e23fc8a0 | |||
7ab66113ac | |||
2909676ed3 | |||
5af9236c19 | |||
04c0f47559 | |||
a91997683b | |||
014dd7d660 | |||
a1b633e4db | |||
57496c9b46 | |||
c4f38f1de6 | |||
e7cf0c2f79 | |||
15ffd0738c | |||
21ff8ba797 | |||
b2ce071ef0 | |||
65545c78c6 | |||
4279151238 | |||
3d3f55a931 | |||
85569c032a | |||
bd9daddaef | |||
af31875e6a | |||
a0766159f2 | |||
316617c396 | |||
402b766872 | |||
8fade614d2 | |||
d8c980f56f | |||
b36572c5be | |||
fcd72e1f98 | |||
1cbab78989 | |||
28df6e35fb | |||
12172f11c0 | |||
00c7abc38d | |||
0cceed51f8 | |||
d866d5b53b | |||
05bc21b660 | |||
9455824a2d | |||
0b659ba124 | |||
d82d3701dd | |||
5db3506cba | |||
c00f7d0852 | |||
d900ca060a | |||
b179f7b441 | |||
506e740438 | |||
731ad57fd2 | |||
9fd7d6bd34 | |||
7ca5c92769 | |||
96d44bd651 | |||
ee9e092129 | |||
f0334d5755 | |||
1084381bbf | |||
7d90b47875 |
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -5,7 +5,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to suggest an idea for Shortify!
|
Thanks for taking the time to suggest an idea for Slash!
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Is your feature request related to a problem?
|
label: Is your feature request related to a problem?
|
||||||
|
@ -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/shortify:latest, stevenlgtm/shortify:${{ env.VERSION }}
|
tags: stevenlgtm/slash:latest, stevenlgtm/slash:${{ env.VERSION }}
|
||||||
|
@ -34,4 +34,4 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: stevenlgtm/shortify:test
|
tags: stevenlgtm/slash:test
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -11,5 +11,3 @@ web/dist
|
|||||||
build
|
build
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
extension
|
|
||||||
|
16
Dockerfile
16
Dockerfile
@ -17,18 +17,24 @@ WORKDIR /backend-build
|
|||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend /frontend-build/dist ./server/dist
|
COPY --from=frontend /frontend-build/dist ./server/dist
|
||||||
|
|
||||||
RUN go build -o shortify ./cmd/shortify/main.go
|
RUN CGO_ENABLED=0 go build -o slash ./cmd/slash/main.go
|
||||||
|
|
||||||
# Make workspace with above generated files.
|
# Make workspace with above generated files.
|
||||||
FROM alpine:3.16 AS monolithic
|
FROM alpine:3.16 AS monolithic
|
||||||
WORKDIR /usr/local/shortify
|
WORKDIR /usr/local/slash
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
ENV TZ="UTC"
|
ENV TZ="UTC"
|
||||||
|
|
||||||
COPY --from=backend /backend-build/shortify /usr/local/shortify/
|
COPY --from=backend /backend-build/slash /usr/local/slash/
|
||||||
|
|
||||||
|
EXPOSE 5231
|
||||||
|
|
||||||
# Directory to store the data, which can be referenced as the mounting point.
|
# Directory to store the data, which can be referenced as the mounting point.
|
||||||
RUN mkdir -p /var/opt/shortify
|
RUN mkdir -p /var/opt/slash
|
||||||
|
VOLUME /var/opt/slash
|
||||||
|
|
||||||
ENTRYPOINT ["./shortify", "--mode", "prod", "--port", "5231"]
|
ENV SLASH_MODE="prod"
|
||||||
|
ENV SLASH_PORT="5231"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./slash"]
|
||||||
|
21
README.md
21
README.md
@ -1,17 +1,22 @@
|
|||||||
# Shortify
|
# Slash
|
||||||
|
|
||||||
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
<img align="right" src="./resources/logo.png" height="64px" alt="logo">
|
||||||
|
|
||||||
**Shortify** 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. It simplifies link organization, management, and collaboration, making it effortless to navigate and share web resources.
|
**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.
|
||||||
|
|
||||||
Let's Simplify, Share, and Save your links with **Shortify**.
|
Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Create customizable `/s/` short links for any URL.
|
||||||
|
- Share short links privately or with others.
|
||||||
|
- View analytics on short link traffic and sources.
|
||||||
|
- 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 shortify -p 5231:5231 -v ~/.shortify/:/var/opt/shortify stevenlgtm/shortify:latest
|
docker run -d --name slash -p 5231:5231 -v ~/.slash/:/var/opt/slash stevenlgtm/slash:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||

|
|
||||||
|
128
api/v1/analytics.go
Normal file
128
api/v1/analytics.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/boojack/slash/store"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mssola/useragent"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReferenceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisData struct {
|
||||||
|
ReferenceData []ReferenceInfo `json:"referenceData"`
|
||||||
|
DeviceData []DeviceInfo `json:"deviceData"`
|
||||||
|
BrowserData []BrowserInfo `json:"browserData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) registerAnalyticsRoutes(g *echo.Group) {
|
||||||
|
g.GET("/shortcut/:shortcutId/analytics", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
|
||||||
|
}
|
||||||
|
activities, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcutID)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get activities, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
referenceMap := make(map[string]int)
|
||||||
|
deviceMap := make(map[string]int)
|
||||||
|
browserMap := make(map[string]int)
|
||||||
|
for _, activity := range activities {
|
||||||
|
payload := &ActivityShorcutViewPayload{}
|
||||||
|
if err := json.Unmarshal([]byte(activity.Payload), payload); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to unmarshal payload, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := referenceMap[payload.Referer]; !ok {
|
||||||
|
referenceMap[payload.Referer] = 0
|
||||||
|
}
|
||||||
|
referenceMap[payload.Referer]++
|
||||||
|
|
||||||
|
ua := useragent.New(payload.UserAgent)
|
||||||
|
deviceName := ua.OSInfo().Name
|
||||||
|
browserName, _ := ua.Browser()
|
||||||
|
|
||||||
|
if _, ok := deviceMap[deviceName]; !ok {
|
||||||
|
deviceMap[deviceName] = 0
|
||||||
|
}
|
||||||
|
deviceMap[deviceName]++
|
||||||
|
|
||||||
|
if _, ok := browserMap[browserName]; !ok {
|
||||||
|
browserMap[browserName] = 0
|
||||||
|
}
|
||||||
|
browserMap[browserName]++
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, &AnalysisData{
|
||||||
|
ReferenceData: mapToReferenceInfoSlice(referenceMap),
|
||||||
|
DeviceData: mapToDeviceInfoSlice(deviceMap),
|
||||||
|
BrowserData: mapToBrowserInfoSlice(browserMap),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToReferenceInfoSlice(m map[string]int) []ReferenceInfo {
|
||||||
|
referenceInfoSlice := make([]ReferenceInfo, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
referenceInfoSlice = append(referenceInfoSlice, ReferenceInfo{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(referenceInfoSlice, func(i, j ReferenceInfo) bool {
|
||||||
|
return i.Count > j.Count
|
||||||
|
})
|
||||||
|
return referenceInfoSlice
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToDeviceInfoSlice(m map[string]int) []DeviceInfo {
|
||||||
|
deviceInfoSlice := make([]DeviceInfo, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
deviceInfoSlice = append(deviceInfoSlice, DeviceInfo{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(deviceInfoSlice, func(i, j DeviceInfo) bool {
|
||||||
|
return i.Count > j.Count
|
||||||
|
})
|
||||||
|
return deviceInfoSlice
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapToBrowserInfoSlice(m map[string]int) []BrowserInfo {
|
||||||
|
browserInfoSlice := make([]BrowserInfo, 0)
|
||||||
|
for key, value := range m {
|
||||||
|
browserInfoSlice = append(browserInfoSlice, BrowserInfo{
|
||||||
|
Name: key,
|
||||||
|
Count: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(browserInfoSlice, func(i, j BrowserInfo) bool {
|
||||||
|
return i.Count > j.Count
|
||||||
|
})
|
||||||
|
return browserInfoSlice
|
||||||
|
}
|
@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/auth"
|
"github.com/boojack/slash/api/v1/auth"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
@ -5,14 +5,14 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
issuer = "shortify"
|
issuer = "slash"
|
||||||
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
// Signing key section. For now, this is only used for signing, not for verifying since we only
|
||||||
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
// have 1 version. But it will be used to maintain backward compatibility if we change the signing mechanism.
|
||||||
keyID = "v1"
|
keyID = "v1"
|
||||||
@ -33,11 +33,9 @@ const (
|
|||||||
// 2. 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 = "access-token"
|
AccessTokenCookieName = "slash.access-token"
|
||||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||||
RefreshTokenCookieName = "refresh-token"
|
RefreshTokenCookieName = "slash.refresh-token"
|
||||||
// UserIDCookieName is the cookie name of user ID.
|
|
||||||
UserIDCookieName = "user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type claimsMessage struct {
|
type claimsMessage struct {
|
@ -7,9 +7,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/shortify/internal/util"
|
"github.com/boojack/slash/api/v1/auth"
|
||||||
"github.com/boojack/shortify/server/auth"
|
"github.com/boojack/slash/internal/util"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@ -77,16 +77,18 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
path := c.Path()
|
path := c.Path()
|
||||||
method := c.Request().Method
|
method := c.Request().Method
|
||||||
|
|
||||||
if defaultAuthSkipper(c) {
|
// Pass auth and profile endpoints.
|
||||||
|
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
token := findAccessToken(c)
|
token := findAccessToken(c)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
||||||
if util.HasPrefixes(path, "/api/v1/workspace/profile", "/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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,9 +104,7 @@ 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 := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
generateToken := time.Until(claims.ExpiresAt.Time) < auth.RefreshThresholdDuration
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ve *jwt.ValidationError
|
var ve *jwt.ValidationError
|
||||||
@ -115,10 +115,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
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)
|
||||||
@ -195,8 +200,3 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
|||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAuthSkipper(c echo.Context) bool {
|
|
||||||
path := c.Path()
|
|
||||||
return util.HasPrefixes(path, "/api/v1/auth")
|
|
||||||
}
|
|
||||||
|
@ -5,8 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@ -42,11 +43,43 @@ 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 {
|
||||||
|
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),
|
||||||
|
// 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 = 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 {
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@ -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) {
|
||||||
@ -83,8 +92,13 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
Name: strings.ToLower(create.Name),
|
Name: strings.ToLower(create.Name),
|
||||||
Link: create.Link,
|
Link: create.Link,
|
||||||
Description: create.Description,
|
Description: create.Description,
|
||||||
Visibility: convertVisibilityToStore(create.Visibility),
|
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)
|
||||||
@ -176,13 +197,6 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
find := &store.FindShortcut{}
|
find := &store.FindShortcut{}
|
||||||
if creatorIDStr := c.QueryParam("creatorId"); creatorIDStr != "" {
|
|
||||||
creatorID, err := strconv.Atoi(creatorIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("unwanted creator id string: %s", creatorIDStr))
|
|
||||||
}
|
|
||||||
find.CreatorID = &creatorID
|
|
||||||
}
|
|
||||||
if tag := c.QueryParam("tag"); tag != "" {
|
if tag := c.QueryParam("tag"); tag != "" {
|
||||||
find.Tag = &tag
|
find.Tag = &tag
|
||||||
}
|
}
|
||||||
@ -268,16 +282,68 @@ 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) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||||
|
if shortcut == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &shortcut.CreatorID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Failed to get creator")
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("Creator not found")
|
||||||
|
}
|
||||||
|
shortcut.Creator = convertUserFromStore(user)
|
||||||
|
|
||||||
|
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||||
|
Type: store.ActivityShortcutView,
|
||||||
|
Level: store.ActivityInfo,
|
||||||
|
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Failed to list activities")
|
||||||
|
}
|
||||||
|
shortcut.View = len(activityList)
|
||||||
|
|
||||||
|
return shortcut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
||||||
|
tags := []string{}
|
||||||
|
if shortcut.Tag != "" {
|
||||||
|
tags = append(tags, strings.Split(shortcut.Tag, " ")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Shortcut{
|
||||||
|
ID: shortcut.ID,
|
||||||
|
CreatedTs: shortcut.CreatedTs,
|
||||||
|
UpdatedTs: shortcut.UpdatedTs,
|
||||||
|
CreatorID: shortcut.CreatorID,
|
||||||
|
Name: shortcut.Name,
|
||||||
|
Link: shortcut.Link,
|
||||||
|
Description: shortcut.Description,
|
||||||
|
Visibility: Visibility(shortcut.Visibility),
|
||||||
|
RowStatus: RowStatus(shortcut.RowStatus),
|
||||||
|
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 {
|
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *store.Shortcut) error {
|
||||||
payload := &ActivityShorcutCreatePayload{
|
payload := &ActivityShorcutCreatePayload{
|
||||||
ShortcutID: shortcut.ID,
|
ShortcutID: shortcut.ID,
|
||||||
@ -298,62 +364,3 @@ func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcu
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
|
||||||
if shortcut == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
|
||||||
ID: &shortcut.CreatorID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Failed to get creator")
|
|
||||||
}
|
|
||||||
shortcut.Creator = convertUserFromStore(user)
|
|
||||||
|
|
||||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
|
||||||
Type: store.ActivityShortcutView,
|
|
||||||
Level: store.ActivityInfo,
|
|
||||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Failed to list activities")
|
|
||||||
}
|
|
||||||
shortcut.View = len(activityList)
|
|
||||||
|
|
||||||
return shortcut, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertVisibilityToStore(visibility Visibility) store.Visibility {
|
|
||||||
switch visibility {
|
|
||||||
case VisibilityPrivate:
|
|
||||||
return store.VisibilityPrivate
|
|
||||||
case VisibilityWorkspace:
|
|
||||||
return store.VisibilityWorkspace
|
|
||||||
case VisibilityPublic:
|
|
||||||
return store.VisibilityPublic
|
|
||||||
default:
|
|
||||||
return store.VisibilityPrivate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertShortcutFromStore(shortcut *store.Shortcut) *Shortcut {
|
|
||||||
tags := []string{}
|
|
||||||
if shortcut.Tag != "" {
|
|
||||||
tags = append(tags, strings.Split(shortcut.Tag, " ")...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Shortcut{
|
|
||||||
ID: shortcut.ID,
|
|
||||||
CreatedTs: shortcut.CreatedTs,
|
|
||||||
UpdatedTs: shortcut.UpdatedTs,
|
|
||||||
CreatorID: shortcut.CreatorID,
|
|
||||||
Name: shortcut.Name,
|
|
||||||
Link: shortcut.Link,
|
|
||||||
Description: shortcut.Description,
|
|
||||||
Visibility: Visibility(shortcut.Visibility),
|
|
||||||
RowStatus: RowStatus(shortcut.RowStatus),
|
|
||||||
Tags: tags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
func (*APIV1Service) registerURLUtilRoutes(g *echo.Group) {
|
||||||
|
// GET /url/favicon?url=...
|
||||||
g.GET("/url/favicon", func(c echo.Context) error {
|
g.GET("/url/favicon", func(c echo.Context) error {
|
||||||
url := c.QueryParam("url")
|
url := c.QueryParam("url")
|
||||||
icons, err := favicon.Find(url)
|
icons, err := favicon.Find(url)
|
||||||
|
@ -7,7 +7,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@ -56,7 +56,7 @@ type CreateUserRequest struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Role Role `json:"-"`
|
Role Role `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (create CreateUserRequest) Validate() error {
|
func (create CreateUserRequest) Validate() error {
|
||||||
@ -78,13 +78,56 @@ type PatchUserRequest struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Nickname *string `json:"nickname"`
|
Nickname *string `json:"nickname"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
}
|
Role *Role `json:"role"`
|
||||||
|
|
||||||
type UserDelete struct {
|
|
||||||
ID int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||||
|
g.POST("/user", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
|
||||||
|
}
|
||||||
|
if currentUser.Role != store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user")
|
||||||
|
}
|
||||||
|
|
||||||
|
userCreate := &CreateUserRequest{}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if err := userCreate.Validate(); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.Store.CreateUser(ctx, &store.User{
|
||||||
|
Role: store.Role(userCreate.Role),
|
||||||
|
Email: userCreate.Email,
|
||||||
|
Nickname: userCreate.Nickname,
|
||||||
|
PasswordHash: string(passwordHash),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage := convertUserFromStore(user)
|
||||||
|
return c.JSON(http.StatusOK, userMessage)
|
||||||
|
})
|
||||||
|
|
||||||
g.GET("/user", func(c echo.Context) error {
|
g.GET("/user", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
@ -144,7 +187,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
}
|
}
|
||||||
if currentUserID != userID {
|
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: ¤tUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||||
|
}
|
||||||
|
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
||||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,13 +206,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUser := &store.UpdateUser{
|
updateUser := &store.UpdateUser{
|
||||||
ID: currentUserID,
|
ID: userID,
|
||||||
}
|
}
|
||||||
if userPatch.Email != nil {
|
if userPatch.Email != nil {
|
||||||
if !validateEmail(*userPatch.Email) {
|
if !validateEmail(*userPatch.Email) {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUser.Email = userPatch.Email
|
updateUser.Email = userPatch.Email
|
||||||
}
|
}
|
||||||
if userPatch.Nickname != nil {
|
if userPatch.Nickname != nil {
|
||||||
@ -175,6 +226,14 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
passwordHashStr := string(passwordHash)
|
passwordHashStr := string(passwordHash)
|
||||||
updateUser.PasswordHash = &passwordHashStr
|
updateUser.PasswordHash = &passwordHashStr
|
||||||
}
|
}
|
||||||
|
if userPatch.RowStatus != nil {
|
||||||
|
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
||||||
|
updateUser.RowStatus = &rowStatus
|
||||||
|
}
|
||||||
|
if userPatch.Role != nil {
|
||||||
|
role := store.Role(*userPatch.Role)
|
||||||
|
updateUser.Role = &role
|
||||||
|
}
|
||||||
|
|
||||||
user, err := s.Store.UpdateUser(ctx, updateUser)
|
user, err := s.Store.UpdateUser(ctx, updateUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -207,6 +266,18 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||||
}
|
}
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||||
|
ID: &userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
if user.Role == store.RoleAdmin {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
|
@ -13,11 +13,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// String returns the string format of UserSettingKey type.
|
// String returns the string format of UserSettingKey type.
|
||||||
func (key UserSettingKey) String() string {
|
func (k UserSettingKey) String() string {
|
||||||
if key == UserSettingLocaleKey {
|
return string(k)
|
||||||
return "locale"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -27,7 +24,7 @@ var (
|
|||||||
type UserSetting struct {
|
type UserSetting struct {
|
||||||
UserID int
|
UserID int
|
||||||
Key UserSettingKey `json:"key"`
|
Key UserSettingKey `json:"key"`
|
||||||
// Value is a JSON string with basic value
|
// Value is a JSON string with basic value.
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
@ -29,6 +29,7 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
|||||||
s.registerAuthRoutes(apiV1Group, secret)
|
s.registerAuthRoutes(apiV1Group, secret)
|
||||||
s.registerUserRoutes(apiV1Group)
|
s.registerUserRoutes(apiV1Group)
|
||||||
s.registerShortcutRoutes(apiV1Group)
|
s.registerShortcutRoutes(apiV1Group)
|
||||||
|
s.registerAnalyticsRoutes(apiV1Group)
|
||||||
|
|
||||||
redirectorGroup := apiGroup.Group("/s")
|
redirectorGroup := apiGroup.Group("/s")
|
||||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -12,21 +12,14 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server"
|
"github.com/boojack/slash/server"
|
||||||
_profile "github.com/boojack/shortify/server/profile"
|
_profile "github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/boojack/shortify/store/db"
|
"github.com/boojack/slash/store/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
greetingBanner = `
|
greetingBanner = `Welcome to Slash!`
|
||||||
███████╗██╗ ██╗ ██████╗ ██████╗ ████████╗██╗███████╗██╗ ██╗
|
|
||||||
██╔════╝██║ ██║██╔═══██╗██╔══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
|
|
||||||
███████╗███████║██║ ██║██████╔╝ ██║ ██║█████╗ ╚████╔╝
|
|
||||||
╚════██║██╔══██║██║ ██║██╔══██╗ ██║ ██║██╔══╝ ╚██╔╝
|
|
||||||
███████║██║ ██║╚██████╔╝██║ ██║ ██║ ██║██║ ██║
|
|
||||||
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
|
|
||||||
`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -36,8 +29,8 @@ var (
|
|||||||
data string
|
data string
|
||||||
|
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "shortify",
|
Use: "slash",
|
||||||
Short: "",
|
Short: `A bookmarking and url shortener, save and share your links very easily.`,
|
||||||
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)
|
||||||
@ -89,7 +82,7 @@ func Execute() error {
|
|||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "dev", `mode of server, can be "prod" or "dev"`)
|
rootCmd.PersistentFlags().StringVarP(&mode, "mode", "m", "demo", `mode of server, can be "prod" or "dev" or "demo"`)
|
||||||
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
|
rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8082, "port of server")
|
||||||
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
rootCmd.PersistentFlags().StringVarP(&data, "data", "d", "", "data directory")
|
||||||
|
|
||||||
@ -106,9 +99,9 @@ func init() {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.SetDefault("mode", "dev")
|
viper.SetDefault("mode", "demo")
|
||||||
viper.SetDefault("port", 8082)
|
viper.SetDefault("port", 8082)
|
||||||
viper.SetEnvPrefix("shortify")
|
viper.SetEnvPrefix("slash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
21
extension/background.js
Normal file
21
extension/background.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { getSlashData } from "./common.js";
|
||||||
|
|
||||||
|
const urlRegex = /https?:\/\/s\/(.+)/;
|
||||||
|
|
||||||
|
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||||
|
if (typeof tab.url === "string") {
|
||||||
|
const matchResult = urlRegex.exec(tab.url);
|
||||||
|
if (matchResult) {
|
||||||
|
const slashData = await getSlashData();
|
||||||
|
const name = matchResult[1];
|
||||||
|
const url = `${slashData.domain}/s/${name}`;
|
||||||
|
return chrome.tabs.update(tab.id, { url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.omnibox.onInputEntered.addListener(async (text) => {
|
||||||
|
const slashData = await getSlashData();
|
||||||
|
const url = `${slashData.domain}/s/${text}`;
|
||||||
|
return chrome.tabs.update({ url });
|
||||||
|
});
|
11
extension/common.js
Normal file
11
extension/common.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const getSlashData = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.storage.local.get(["slash"], (data) => {
|
||||||
|
if (data?.slash) {
|
||||||
|
resolve(data.slash);
|
||||||
|
} else {
|
||||||
|
reject("slash data not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
18
extension/manifest.json
Normal file
18
extension/manifest.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Slash",
|
||||||
|
"description": "",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"manifest_version": 3,
|
||||||
|
"omnibox": {
|
||||||
|
"keyword": "s/"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"permissions": ["tabs", "activeTab", "storage"],
|
||||||
|
"host_permissions": ["*://s/*"]
|
||||||
|
}
|
14
extension/popup.html
Normal file
14
extension/popup.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Slash extension</h2>
|
||||||
|
<div>
|
||||||
|
<span>Domain</span>
|
||||||
|
<input id="domain-input" type="text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button id="save-button">Save</button>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
23
extension/popup.js
Normal file
23
extension/popup.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { getSlashData } from "./common.js";
|
||||||
|
|
||||||
|
const saveButton = document.body.querySelector("#save-button");
|
||||||
|
const domainInput = document.body.querySelector("#domain-input");
|
||||||
|
|
||||||
|
saveButton.addEventListener("click", () => {
|
||||||
|
chrome.storage.local.set({
|
||||||
|
slash: {
|
||||||
|
domain: domainInput.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const slashData = await getSlashData();
|
||||||
|
if (slashData) {
|
||||||
|
domainInput.value = slashData.domain;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing.
|
||||||
|
}
|
||||||
|
})();
|
6
go.mod
6
go.mod
@ -1,4 +1,4 @@
|
|||||||
module github.com/boojack/shortify
|
module github.com/boojack/slash
|
||||||
|
|
||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
@ -66,8 +66,10 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||||
|
github.com/mssola/useragent v1.0.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
go.deanishe.net/favicon v0.1.0
|
go.deanishe.net/favicon v0.1.0
|
||||||
golang.org/x/mod v0.8.0
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df
|
||||||
|
golang.org/x/mod v0.11.0
|
||||||
modernc.org/sqlite v1.23.1
|
modernc.org/sqlite v1.23.1
|
||||||
)
|
)
|
||||||
|
8
go.sum
8
go.sum
@ -217,6 +217,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/mssola/useragent v1.0.0 h1:WRlDpXyxHDNfvZaPEut5Biveq86Ze4o4EMffyMxmH5o=
|
||||||
|
github.com/mssola/useragent v1.0.0/go.mod h1:hz9Cqz4RXusgg1EdI4Al0INR62kP7aPSRNHnpU+b85Y=
|
||||||
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
|
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
@ -316,6 +318,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
|||||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||||
|
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
@ -339,8 +343,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.1 MiB |
@ -2,8 +2,8 @@ root = "."
|
|||||||
tmp_dir = ".air"
|
tmp_dir = ".air"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
bin = "./.air/shortify"
|
bin = "./.air/slash --mode dev"
|
||||||
cmd = "go build -o ./.air/shortify ./cmd/shortify/main.go"
|
cmd = "go build -o ./.air/slash ./cmd/slash/main.go"
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = [".air", "web", "build"]
|
exclude_dir = [".air", "web", "build"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Usage: ./scripts/build.sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/../"
|
|
||||||
|
|
||||||
echo "Start building backend..."
|
|
||||||
|
|
||||||
go build -o ./build/shortify ./cmd/shortify/main.go
|
|
||||||
|
|
||||||
echo "Backend built!"
|
|
@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Usage: ./scripts/start.sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/../"
|
|
||||||
|
|
||||||
air -c ./scripts/.air.toml
|
|
@ -5,7 +5,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/boojack/shortify/internal/util"
|
"github.com/boojack/slash/internal/util"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
)
|
)
|
||||||
@ -22,16 +22,16 @@ func getFileSystem(path string) http.FileSystem {
|
|||||||
return http.FS(fs)
|
return http.FS(fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultAPIRequestSkipper(c echo.Context) bool {
|
func defaultRequestSkipper(c echo.Context) bool {
|
||||||
path := c.Path()
|
path := c.Path()
|
||||||
return util.HasPrefixes(path, "/api")
|
return util.HasPrefixes(path, "/api/", "/s/*")
|
||||||
}
|
}
|
||||||
|
|
||||||
func embedFrontend(e *echo.Echo) {
|
func embedFrontend(e *echo.Echo) {
|
||||||
// Use echo static middleware to serve the built dist folder
|
// Use echo static middleware to serve the built dist folder
|
||||||
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
|
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
|
||||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
Skipper: defaultAPIRequestSkipper,
|
Skipper: defaultRequestSkipper,
|
||||||
HTML5: true,
|
HTML5: true,
|
||||||
Filesystem: getFileSystem("dist"),
|
Filesystem: getFileSystem("dist"),
|
||||||
}))
|
}))
|
||||||
@ -44,7 +44,7 @@ func embedFrontend(e *echo.Echo) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
Skipper: defaultAPIRequestSkipper,
|
Skipper: defaultRequestSkipper,
|
||||||
HTML5: true,
|
HTML5: true,
|
||||||
Filesystem: getFileSystem("dist/assets"),
|
Filesystem: getFileSystem("dist/assets"),
|
||||||
}))
|
}))
|
||||||
|
@ -4,38 +4,44 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/version"
|
"github.com/boojack/slash/server/version"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Profile is the configuration to start main server.
|
// Profile is the configuration to start main server.
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
// Data is the data directory
|
|
||||||
Data string `json:"-"`
|
|
||||||
// DSN points to where Shortify stores its own data
|
|
||||||
DSN string `json:"-"`
|
|
||||||
// Mode can be "prod" or "dev"
|
// Mode can be "prod" or "dev"
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
// Port is the binding port for server
|
// Port is the binding port for server
|
||||||
Port int `json:"port"`
|
Port int `json:"-"`
|
||||||
|
// Data is the data directory
|
||||||
|
Data string `json:"-"`
|
||||||
|
// DSN points to where slash stores its own data
|
||||||
|
DSN string `json:"-"`
|
||||||
// Version is the current version of server
|
// Version is the current version of server
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Profile) IsDev() bool {
|
||||||
|
return p.Mode != "prod"
|
||||||
|
}
|
||||||
|
|
||||||
func checkDSN(dataDir string) (string, error) {
|
func checkDSN(dataDir string) (string, error) {
|
||||||
// Convert to absolute path if relative path is supplied.
|
// Convert to absolute path if relative path is supplied.
|
||||||
if !filepath.IsAbs(dataDir) {
|
if !filepath.IsAbs(dataDir) {
|
||||||
absDir, err := filepath.Abs(filepath.Dir(os.Args[0]) + "/" + dataDir)
|
relativeDir := filepath.Join(filepath.Dir(os.Args[0]), dataDir)
|
||||||
|
absDir, err := filepath.Abs(relativeDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dataDir = absDir
|
dataDir = absDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim trailing / in case user supplies
|
// Trim trailing \ or / in case user supplies
|
||||||
dataDir = strings.TrimRight(dataDir, "/")
|
dataDir = strings.TrimRight(dataDir, "\\/")
|
||||||
|
|
||||||
if _, err := os.Stat(dataDir); err != nil {
|
if _, err := os.Stat(dataDir); err != nil {
|
||||||
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
|
return "", fmt.Errorf("unable to access data folder %s, err %w", dataDir, err)
|
||||||
@ -44,7 +50,7 @@ func checkDSN(dataDir string) (string, error) {
|
|||||||
return dataDir, nil
|
return dataDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDevProfile will return a profile for dev or prod.
|
// GetProfile will return a profile for dev or prod.
|
||||||
func GetProfile() (*Profile, error) {
|
func GetProfile() (*Profile, error) {
|
||||||
profile := Profile{}
|
profile := Profile{}
|
||||||
err := viper.Unmarshal(&profile)
|
err := viper.Unmarshal(&profile)
|
||||||
@ -52,12 +58,23 @@ func GetProfile() (*Profile, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if profile.Mode != "dev" && profile.Mode != "prod" {
|
if profile.Mode != "demo" && profile.Mode != "dev" && profile.Mode != "prod" {
|
||||||
profile.Mode = "dev"
|
profile.Mode = "demo"
|
||||||
}
|
}
|
||||||
|
|
||||||
if profile.Mode == "prod" && profile.Data == "" {
|
if profile.Mode == "prod" && profile.Data == "" {
|
||||||
profile.Data = "/var/opt/shortify"
|
if runtime.GOOS == "windows" {
|
||||||
|
profile.Data = filepath.Join(os.Getenv("ProgramData"), "slash")
|
||||||
|
|
||||||
|
if _, err := os.Stat(profile.Data); os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(profile.Data, 0770); err != nil {
|
||||||
|
fmt.Printf("Failed to create data directory: %s, err: %+v\n", profile.Data, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
profile.Data = "/var/opt/slash"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dataDir, err := checkDSN(profile.Data)
|
dataDir, err := checkDSN(profile.Data)
|
||||||
@ -67,7 +84,9 @@ func GetProfile() (*Profile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile.Data = dataDir
|
profile.Data = dataDir
|
||||||
profile.DSN = fmt.Sprintf("%s/shortify_%s.db", dataDir, profile.Mode)
|
dbFile := fmt.Sprintf("slash_%s.db", profile.Mode)
|
||||||
|
profile.DSN = filepath.Join(dataDir, dbFile)
|
||||||
profile.Version = version.GetCurrentVersion(profile.Mode)
|
profile.Version = version.GetCurrentVersion(profile.Mode)
|
||||||
|
|
||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
apiv1 "github.com/boojack/shortify/api/v1"
|
apiv1 "github.com/boojack/slash/api/v1"
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@ -52,7 +52,7 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||||||
embedFrontend(e)
|
embedFrontend(e)
|
||||||
|
|
||||||
// In dev mode, we'd like to set the const secret key to make signin session persistence.
|
// In dev mode, we'd like to set the const secret key to make signin session persistence.
|
||||||
secret := "shortify"
|
secret := "slash"
|
||||||
if profile.Mode == "prod" {
|
if profile.Mode == "prod" {
|
||||||
var err error
|
var err error
|
||||||
secret, err = s.getSystemSecretSessionName(ctx)
|
secret, err = s.getSystemSecretSessionName(ctx)
|
||||||
@ -61,10 +61,10 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apiGroup := e.Group("")
|
rootGroup := e.Group("")
|
||||||
// Register API v1 routes.
|
// Register API v1 routes.
|
||||||
apiV1Service := apiv1.NewAPIV1Service(profile, store)
|
apiV1Service := apiv1.NewAPIV1Service(profile, store)
|
||||||
apiV1Service.Start(apiGroup, secret)
|
apiV1Service.Start(rootGroup, secret)
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
@ -9,13 +9,13 @@ 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.1.0"
|
var Version = "0.3.0"
|
||||||
|
|
||||||
// DevVersion is the service current development version.
|
// DevVersion is the service current development version.
|
||||||
var DevVersion = "0.1.0"
|
var DevVersion = "0.3.0"
|
||||||
|
|
||||||
func GetCurrentVersion(mode string) string {
|
func GetCurrentVersion(mode string) string {
|
||||||
if mode == "dev" {
|
if mode == "dev" || mode == "demo" {
|
||||||
return DevVersion
|
return DevVersion
|
||||||
}
|
}
|
||||||
return Version
|
return Version
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -12,20 +12,23 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/server/version"
|
"github.com/boojack/slash/server/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migration
|
//go:embed migration
|
||||||
var migrationFS embed.FS
|
var migrationFS embed.FS
|
||||||
|
|
||||||
|
//go:embed seed
|
||||||
|
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,
|
||||||
@ -39,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)
|
||||||
}
|
}
|
||||||
@ -49,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 {
|
||||||
@ -89,7 +105,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
return fmt.Errorf("failed to read raw database file, err: %w", err)
|
||||||
}
|
}
|
||||||
backupDBFilePath := fmt.Sprintf("%s/shortify_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
backupDBFilePath := fmt.Sprintf("%s/slash_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
|
||||||
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
return fmt.Errorf("failed to write raw database file, err: %w", err)
|
||||||
}
|
}
|
||||||
@ -119,6 +135,12 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
|||||||
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: %w", err)
|
||||||
}
|
}
|
||||||
|
// In demo mode, we should seed the database.
|
||||||
|
if db.profile.Mode == "demo" {
|
||||||
|
if err := db.seed(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to seed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,36 +190,46 @@ 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 {
|
||||||
|
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read seed files, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(filenames)
|
||||||
|
|
||||||
|
// Loop over all seed files and execute them in order.
|
||||||
|
for _, filename := range filenames {
|
||||||
|
buf, err := seedFS.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
|
||||||
|
}
|
||||||
|
stmt := string(buf)
|
||||||
|
if err := db.execute(ctx, stmt); err != nil {
|
||||||
|
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
@ -22,6 +22,8 @@ CREATE TABLE user (
|
|||||||
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_email ON user(email);
|
||||||
|
|
||||||
-- user_setting
|
-- user_setting
|
||||||
CREATE TABLE user_setting (
|
CREATE TABLE user_setting (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
@ -41,9 +43,12 @@ 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);
|
||||||
|
|
||||||
-- activity
|
-- activity
|
||||||
CREATE TABLE activity (
|
CREATE TABLE activity (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
2
store/db/migration/prod/0.2/00__create_index.sql
Normal file
2
store/db/migration/prod/0.2/00__create_index.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS idx_user_email ON user(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS 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 '{}';
|
@ -22,6 +22,8 @@ CREATE TABLE user (
|
|||||||
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
role TEXT NOT NULL CHECK (role IN ('ADMIN', 'USER')) DEFAULT 'USER'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_email ON user(email);
|
||||||
|
|
||||||
-- user_setting
|
-- user_setting
|
||||||
CREATE TABLE user_setting (
|
CREATE TABLE user_setting (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
@ -41,9 +43,12 @@ 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);
|
||||||
|
|
||||||
-- activity
|
-- activity
|
||||||
CREATE TABLE activity (
|
CREATE TABLE activity (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
@ -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 {
|
||||||
|
9
store/db/seed/10000__reset.sql
Normal file
9
store/db/seed/10000__reset.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
DELETE FROM activity;
|
||||||
|
|
||||||
|
DELETE FROM shortcut;
|
||||||
|
|
||||||
|
DELETE FROM user_setting;
|
||||||
|
|
||||||
|
DELETE FROM user;
|
||||||
|
|
||||||
|
DELETE FROM workspace_setting;
|
35
store/db/seed/10001__user.sql
Normal file
35
store/db/seed/10001__user.sql
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
INSERT INTO
|
||||||
|
user (
|
||||||
|
`id`,
|
||||||
|
`role`,
|
||||||
|
`email`,
|
||||||
|
`nickname`,
|
||||||
|
`password_hash`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
101,
|
||||||
|
'ADMIN',
|
||||||
|
'slash@stevenlgtm.com',
|
||||||
|
'Slasher',
|
||||||
|
-- raw password: secret
|
||||||
|
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
user (
|
||||||
|
`id`,
|
||||||
|
`role`,
|
||||||
|
`email`,
|
||||||
|
`nickname`,
|
||||||
|
`password_hash`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
102,
|
||||||
|
'USER',
|
||||||
|
'steven@usememos.com',
|
||||||
|
'Steven',
|
||||||
|
-- raw password: secret
|
||||||
|
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
|
||||||
|
);
|
88
store/db/seed/10002__shortcut.sql
Normal file
88
store/db/seed/10002__shortcut.sql
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
101,
|
||||||
|
'memos',
|
||||||
|
'https://usememos.com',
|
||||||
|
'PUBLIC'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
101,
|
||||||
|
'sqlchat',
|
||||||
|
'https://www.sqlchat.ai',
|
||||||
|
'WORKSPACE'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO
|
||||||
|
shortcut (
|
||||||
|
`id`,
|
||||||
|
`creator_id`,
|
||||||
|
`name`,
|
||||||
|
`link`,
|
||||||
|
`visibility`,
|
||||||
|
`og_metadata`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
3,
|
||||||
|
101,
|
||||||
|
'ai-infra',
|
||||||
|
'https://star-history.com/blog/open-source-ai-infra-projects',
|
||||||
|
'PUBLIC',
|
||||||
|
'{"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`,
|
||||||
|
`og_metadata`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
101,
|
||||||
|
'schema-change',
|
||||||
|
'https://www.bytebase.com/blog/how-to-handle-database-schema-change/#what-is-a-database-schema-change',
|
||||||
|
'PUBLIC',
|
||||||
|
'{"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`,
|
||||||
|
`visibility`
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
5,
|
||||||
|
102,
|
||||||
|
'stevenlgtm',
|
||||||
|
'https://github.com/boojack',
|
||||||
|
'PUBLIC'
|
||||||
|
);
|
@ -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,5 +263,58 @@ 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 {
|
||||||
|
stmt := `
|
||||||
|
DELETE FROM
|
||||||
|
shortcut
|
||||||
|
WHERE
|
||||||
|
creator_id NOT IN (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
user
|
||||||
|
)`
|
||||||
|
_, err := tx.ExecContext(ctx, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store provides database access to all raw objects.
|
// Store provides database access to all raw objects.
|
||||||
|
151
store/user.go
151
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,82 +126,11 @@ 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)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range list {
|
|
||||||
s.userCache.Store(user.ID, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
|
||||||
if find.ID != nil {
|
|
||||||
if cache, ok := s.userCache.Load(*find.ID); ok {
|
|
||||||
return cache.(*User), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
list, err := listUsers(ctx, tx, find)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(list) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return list[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
|
||||||
DELETE FROM user WHERE id = ?
|
|
||||||
`, delete.ID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
// do nothing here to prevent linter warning.
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.userCache.Delete(delete.ID)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, 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 {
|
||||||
@ -251,7 +163,7 @@ func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error)
|
|||||||
WHERE ` + strings.Join(where, " AND ") + `
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
ORDER BY updated_ts DESC, created_ts DESC
|
ORDER BY updated_ts DESC, created_ts DESC
|
||||||
`
|
`
|
||||||
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
|
||||||
}
|
}
|
||||||
@ -279,5 +191,58 @@ func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, user := range list {
|
||||||
|
s.userCache.Store(user.ID, user)
|
||||||
|
}
|
||||||
|
|
||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetUser(ctx context.Context, find *FindUser) (*User, error) {
|
||||||
|
if find.ID != nil {
|
||||||
|
if cache, ok := s.userCache.Load(*find.ID); ok {
|
||||||
|
return cache.(*User), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListUsers(ctx, find)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return list[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteUser(ctx context.Context, delete *DeleteUser) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
DELETE FROM user WHERE id = ?
|
||||||
|
`, delete.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vacuumUserSetting(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := vacuumShortcut(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.userCache.Delete(delete.ID)
|
||||||
|
|
||||||
|
return 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,28 +52,71 @@ 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 {
|
||||||
|
stmt := `
|
||||||
|
DELETE FROM
|
||||||
|
user_setting
|
||||||
|
WHERE
|
||||||
|
user_id NOT IN (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
user
|
||||||
|
)`
|
||||||
|
_, err := tx.ExecContext(ctx, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -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{
|
||||||
|
@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/boojack/shortify/store/db"
|
"github.com/boojack/slash/store/db"
|
||||||
test "github.com/boojack/shortify/test"
|
test "github.com/boojack/slash/test"
|
||||||
|
|
||||||
// sqlite driver.
|
// sqlite driver.
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@ -26,6 +26,13 @@ func TestUserStore(t *testing.T) {
|
|||||||
Nickname: &userPatchNickname,
|
Nickname: &userPatchNickname,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
_, err = ts.CreateShortcut(ctx, &store.Shortcut{
|
||||||
|
CreatorID: user.ID,
|
||||||
|
Name: "test_shortcut",
|
||||||
|
Link: "https://www.google.com",
|
||||||
|
Visibility: store.VisibilityPublic,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
require.Equal(t, userPatchNickname, user.Nickname)
|
require.Equal(t, userPatchNickname, user.Nickname)
|
||||||
err = ts.DeleteUser(ctx, &store.DeleteUser{
|
err = ts.DeleteUser(ctx, &store.DeleteUser{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
@ -34,6 +41,9 @@ func TestUserStore(t *testing.T) {
|
|||||||
users, err = ts.ListUsers(ctx, &store.FindUser{})
|
users, err = ts.ListUsers(ctx, &store.FindUser{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 0, len(users))
|
require.Equal(t, 0, len(users))
|
||||||
|
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, len(shortcuts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// createTestingAdminUser creates a testing admin user.
|
// createTestingAdminUser creates a testing admin user.
|
||||||
|
@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/store"
|
"github.com/boojack/slash/store"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
@ -5,8 +5,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/boojack/shortify/server/profile"
|
"github.com/boojack/slash/server/profile"
|
||||||
"github.com/boojack/shortify/server/version"
|
"github.com/boojack/slash/server/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getUnusedPort() int {
|
func getUnusedPort() int {
|
||||||
@ -31,7 +31,7 @@ func GetTestingProfile(t *testing.T) *profile.Profile {
|
|||||||
Mode: mode,
|
Mode: mode,
|
||||||
Port: port,
|
Port: port,
|
||||||
Data: dir,
|
Data: dir,
|
||||||
DSN: fmt.Sprintf("%s/shortify_%s.db", dir, mode),
|
DSN: fmt.Sprintf("%s/slash_%s.db", dir, mode),
|
||||||
Version: version.GetCurrentVersion(mode),
|
Version: version.GetCurrentVersion(mode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
# Shortify
|
# Slash
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="/logo.png" type="image/*" />
|
<link rel="icon" href="/logo.png" type="image/*" />
|
||||||
<meta name="theme-color" content="#FFFFFF" />
|
<meta name="theme-color" content="#FFFFFF" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<title>Shortify</title>
|
<title>Slash</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "shortify",
|
"name": "slash",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -17,6 +17,7 @@
|
|||||||
"i18next": "^23.2.3",
|
"i18next": "^23.2.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.252.0",
|
"lucide-react": "^0.252.0",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hot-toast": "^2.4.0",
|
"react-hot-toast": "^2.4.0",
|
||||||
|
11
web/pnpm-lock.yaml
generated
11
web/pnpm-lock.yaml
generated
@ -35,6 +35,9 @@ dependencies:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.252.0
|
specifier: ^0.252.0
|
||||||
version: 0.252.0(react@18.2.0)
|
version: 0.252.0(react@18.2.0)
|
||||||
|
qrcode.react:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0(react@18.2.0)
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
@ -2513,6 +2516,14 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/qrcode.react@3.1.0(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/queue-microtask@1.2.3:
|
/queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
@ -1,15 +1,33 @@
|
|||||||
import { CssVarsProvider } from "@mui/joy/styles";
|
import { useEffect, useState } from "react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Outlet } from "react-router-dom";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { globalService } from "./services";
|
||||||
import router from "./routers";
|
import useUserStore from "./stores/v1/user";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
const userStore = useUserStore();
|
||||||
<CssVarsProvider>
|
const [loading, setLoading] = useState(true);
|
||||||
<RouterProvider router={router} />
|
|
||||||
<Toaster position="top-center" />
|
useEffect(() => {
|
||||||
</CssVarsProvider>
|
const initialState = async () => {
|
||||||
);
|
try {
|
||||||
|
await globalService.initialState();
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userStore.fetchCurrentUser();
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing.
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initialState();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{!loading && <Outlet />}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@ -19,12 +19,12 @@ 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">Shortify</span> is a bookmarking and short link service that allows you to save and share links
|
<span className="font-medium">Slash</span> is a bookmarking and short link service that allows you to save and share links
|
||||||
easily.
|
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/shortify">
|
<Link variant="plain" href="https://github.com/boojack/slash">
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
133
web/src/components/AnalyticsDialog.tsx
Normal file
133
web/src/components/AnalyticsDialog.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import * as api from "../helpers/api";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcutId: ShortcutId;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalyticsDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcutId, onClose } = props;
|
||||||
|
const [analytics, setAnalytics] = useState<AnalysisData | null>(null);
|
||||||
|
const [selectedDeviceTab, setSelectedDeviceTab] = useState<"os" | "browser">("browser");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getShortcutAnalytics(shortcutId).then(({ data }) => {
|
||||||
|
setAnalytics(data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-lg font-medium">Analytics</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-full w-80 sm:w-96">
|
||||||
|
{analytics ? (
|
||||||
|
<>
|
||||||
|
<p className="w-full py-1 px-2">Top Sources</p>
|
||||||
|
<div className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
|
<div className="w-full divide-y divide-gray-300">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="py-1 px-2 text-left font-semibold text-sm text-gray-500">Source</span>
|
||||||
|
<span className="py-1 pr-2 text-right font-semibold text-sm text-gray-500">Visitors</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full divide-y divide-gray-200">
|
||||||
|
{analytics.referenceData.map((reference) => (
|
||||||
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="whitespace-nowrap py-2 px-2 text-sm truncate text-gray-900">
|
||||||
|
{reference.name ? (
|
||||||
|
<a className="hover:underline hover:text-blue-600" href={reference.name} target="_blank">
|
||||||
|
{reference.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"Direct"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-4 py-1 px-2 flex flex-row justify-between items-center">
|
||||||
|
<span>Devices</span>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className={`whitespace-nowrap border-b-2 px-1 text-sm font-medium ${
|
||||||
|
selectedDeviceTab === "browser"
|
||||||
|
? "border-blue-600 text-blue-600"
|
||||||
|
: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedDeviceTab("browser")}
|
||||||
|
>
|
||||||
|
Browser
|
||||||
|
</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 className="w-full mt-1 overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
|
||||||
|
{selectedDeviceTab === "browser" ? (
|
||||||
|
<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">Browsers</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.browserData.map((reference) => (
|
||||||
|
<div key={reference.name} className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="whitespace-nowrap py-2 px-2 text-sm text-gray-900 truncate">{reference.name || "Unknown"}</span>
|
||||||
|
<span className="whitespace-nowrap py-2 pr-2 text-sm text-gray-500 text-right shrink-0">{reference.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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 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>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsDialog;
|
@ -2,7 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { userService } from "../services";
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -11,6 +11,7 @@ interface Props {
|
|||||||
|
|
||||||
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
|
const userStore = useUserStore();
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
@ -43,9 +44,8 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
const user = userService.getState().user as User;
|
userStore.patchUser({
|
||||||
await userService.patchUser({
|
id: userStore.getCurrentUser().id,
|
||||||
id: user.id,
|
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||||
|
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";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { showCommonDialog } from "./Alert";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -29,26 +29,34 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
description: "",
|
description: "",
|
||||||
visibility: "PRIVATE",
|
visibility: "PRIVATE",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
openGraphMetadata: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const [showDescriptionAndTag, setShowDescriptionAndTag] = 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 isEditing = !!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 +84,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,27 +105,36 @@ 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 handleDeleteShortcutButtonClick = () => {
|
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!shortcutId) {
|
setPartialState({
|
||||||
return;
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
}
|
openGraphMetadata: {
|
||||||
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
|
title: e.target.value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
showCommonDialog({
|
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
title: "Delete Shortcut",
|
setPartialState({
|
||||||
content: `Are you sure to delete shortcut \`${state.shortcutCreate.name}\`? You can not undo this action.`,
|
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||||
style: "danger",
|
openGraphMetadata: {
|
||||||
onConfirm: async () => {
|
...state.shortcutCreate.openGraphMetadata,
|
||||||
await shortcutService.deleteShortcutById(shortcutId);
|
description: e.target.value,
|
||||||
onClose();
|
},
|
||||||
},
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,6 +153,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({
|
||||||
@ -151,12 +177,12 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
<span className="text-lg font-medium">{isEditing ? "Edit Shortcut" : "Create Shortcut"}</span>
|
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="overflow-y-auto">
|
||||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
<span className="mb-2">
|
<span className="mb-2">
|
||||||
Name <span className="text-red-600">*</span>
|
Name <span className="text-red-600">*</span>
|
||||||
@ -173,30 +199,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</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">
|
||||||
Link <span className="text-red-600">*</span>
|
Destination URL <span className="text-red-600">*</span>
|
||||||
</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="e.g. 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">
|
|
||||||
<span className="mb-2">Tags</span>
|
|
||||||
<Input className="w-full" type="text" placeholder="Separated by spaces" value={tag} onChange={handleTagsInputChange} />
|
|
||||||
</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 className="text-red-600">*</span>
|
Visibility <span className="text-red-600">*</span>
|
||||||
@ -208,23 +220,111 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
))}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 px-2 py-1 rounded-md">
|
||||||
|
{t(`shortcut.visibility.${state.shortcutCreate.visibility.toLowerCase()}.description`)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-between items-center mt-8 space-x-2">
|
<Divider className="text-gray-500">Optional</Divider>
|
||||||
<div>
|
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
|
||||||
{isEditing && (
|
<div
|
||||||
<Button color="danger" variant="plain" onClick={handleDeleteShortcutButtonClick}>
|
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
|
||||||
Delete
|
showDescriptionAndTag ? "bg-gray-100" : ""
|
||||||
</Button>
|
}`}
|
||||||
)}
|
onClick={() => setShowDescriptionAndTag(!showDescriptionAndTag)}
|
||||||
|
>
|
||||||
|
<span className="text-sm">Description and tags</span>
|
||||||
|
<button className="w-7 h-7 p-1 rounded-md">
|
||||||
|
<Icon.ChevronDown className={`w-4 h-auto text-gray-500 ${showDescriptionAndTag ? "transform rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
{showDescriptionAndTag && (
|
||||||
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
<div className="w-full px-2 py-1">
|
||||||
Cancel
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
</Button>
|
<span className="mb-2 text-sm">Description</span>
|
||||||
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
<Input
|
||||||
Save
|
className="w-full"
|
||||||
</Button>
|
type="text"
|
||||||
|
placeholder="Something to describe the url"
|
||||||
|
size="sm"
|
||||||
|
value={state.shortcutCreate.description}
|
||||||
|
onChange={handleDescriptionInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2 text-sm">Tags</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Separated by spaces"
|
||||||
|
size="sm"
|
||||||
|
value={tag}
|
||||||
|
onChange={handleTagsInputChange}
|
||||||
|
/>
|
||||||
|
</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" : ""
|
||||||
|
}`}
|
||||||
|
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={`w-4 h-auto text-gray-500 ${showDescriptionAndTag ? "transform rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</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="The image url"
|
||||||
|
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 - A bookmarking and url shortener"
|
||||||
|
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="A bookmarking and url shortener, save and share your links very easily."
|
||||||
|
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">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
200
web/src/components/CreateUserDialog.tsx
Normal file
200
web/src/components/CreateUserDialog.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||||
|
import { isUndefined } from "lodash-es";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user?: User;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
userCreate: UserCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Role[] = ["USER", "ADMIN"];
|
||||||
|
|
||||||
|
const CreateUserDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { onClose, onConfirm, user } = props;
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [state, setState] = useState<State>({
|
||||||
|
userCreate: {
|
||||||
|
email: "",
|
||||||
|
nickname: "",
|
||||||
|
password: "",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const requestState = useLoading(false);
|
||||||
|
const isCreating = isUndefined(user);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
password: "",
|
||||||
|
role: user.role,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const setPartialState = (partialState: Partial<State>) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
email: e.target.value.toLowerCase(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNicknameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
nickname: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
password: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPartialState({
|
||||||
|
userCreate: Object.assign(state.userCreate, {
|
||||||
|
role: e.target.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBtnClick = async () => {
|
||||||
|
if (isCreating && (!state.userCreate.email || !state.userCreate.nickname || !state.userCreate.password)) {
|
||||||
|
toast.error("Please fill all inputs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
const userPatch: UserPatch = {
|
||||||
|
id: user.id,
|
||||||
|
};
|
||||||
|
if (user.email !== state.userCreate.email) {
|
||||||
|
userPatch.email = state.userCreate.email;
|
||||||
|
}
|
||||||
|
if (user.nickname !== state.userCreate.nickname) {
|
||||||
|
userPatch.nickname = state.userCreate.nickname;
|
||||||
|
}
|
||||||
|
if (user.role !== state.userCreate.role) {
|
||||||
|
userPatch.role = state.userCreate.role;
|
||||||
|
}
|
||||||
|
await userStore.patchUser(userPatch);
|
||||||
|
} else {
|
||||||
|
await userStore.createUser(state.userCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error.response.data.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
|
||||||
|
<span className="text-lg font-medium">{isCreating ? "Create User" : "Edit User"}</span>
|
||||||
|
<Button variant="plain" onClick={onClose}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Email <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="email"
|
||||||
|
placeholder="Unique user email"
|
||||||
|
value={state.userCreate.email}
|
||||||
|
onChange={handleEmailInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Nickname <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nickname"
|
||||||
|
value={state.userCreate.nickname}
|
||||||
|
onChange={handleNicknameInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isCreating && (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Password <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="password"
|
||||||
|
placeholder=""
|
||||||
|
value={state.userCreate.password}
|
||||||
|
onChange={handlePasswordInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||||
|
<span className="mb-2">
|
||||||
|
Role <span className="text-red-600">*</span>
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-base">
|
||||||
|
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Radio key={role} value={role} label={role} />
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
|
||||||
|
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserDialog;
|
31
web/src/components/DemoBanner.tsx
Normal file
31
web/src/components/DemoBanner.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { globalService } from "../services";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
const DemoBanner: React.FC = () => {
|
||||||
|
const {
|
||||||
|
workspaceProfile: {
|
||||||
|
profile: { mode },
|
||||||
|
},
|
||||||
|
} = globalService.getState();
|
||||||
|
const shouldShow = mode === "demo";
|
||||||
|
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
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="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>
|
||||||
|
<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"
|
||||||
|
href="https://github.com/boojack/slash#deploy-with-docker-in-seconds"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DemoBanner;
|
@ -2,8 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { userService } from "../services";
|
import useUserStore from "../stores/v1/user";
|
||||||
import { useAppSelector } from "../stores";
|
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -12,9 +11,10 @@ interface Props {
|
|||||||
|
|
||||||
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
const user = useAppSelector((state) => state.user.user as User);
|
const userStore = useUserStore();
|
||||||
const [email, setEmail] = useState(user.email);
|
const currentUser = userStore.getCurrentUser();
|
||||||
const [nickname, setNickname] = useState(user.nickname);
|
const [email, setEmail] = useState(currentUser.email);
|
||||||
|
const [nickname, setNickname] = useState(currentUser.nickname);
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
|
|
||||||
const handleCloseBtnClick = () => {
|
const handleCloseBtnClick = () => {
|
||||||
@ -39,14 +39,13 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
requestState.setLoading();
|
requestState.setLoading();
|
||||||
try {
|
try {
|
||||||
const user = userService.getState().user as User;
|
await userStore.patchUser({
|
||||||
await userService.patchUser({
|
id: currentUser.id,
|
||||||
id: user.id,
|
|
||||||
email,
|
email,
|
||||||
nickname,
|
nickname,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
toast("Password changed");
|
toast("User information updated");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.response.data.message);
|
toast.error(error.response.data.message);
|
||||||
|
42
web/src/components/FilterView.tsx
Normal file
42
web/src/components/FilterView.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
|
||||||
|
const FilterView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const filter = viewStore.filter;
|
||||||
|
const shouldShowFilters = filter.tag !== undefined || filter.visibility !== undefined;
|
||||||
|
|
||||||
|
if (!shouldShowFilters) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-row justify-start items-center mb-4 pl-2">
|
||||||
|
<span className="text-gray-400">Filters:</span>
|
||||||
|
{filter.tag && (
|
||||||
|
<button
|
||||||
|
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 })}
|
||||||
|
>
|
||||||
|
<Icon.Tag className="w-4 h-auto mr-1" />#{filter.tag}
|
||||||
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filter.visibility && (
|
||||||
|
<button
|
||||||
|
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({ visibility: undefined })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={filter.visibility} />
|
||||||
|
{t(`shortcut.visibility.${filter.visibility.toLowerCase()}.self`)}
|
||||||
|
<Icon.X className="w-4 h-auto ml-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterView;
|
61
web/src/components/GenerateQRCodeDialog.tsx
Normal file
61
web/src/components/GenerateQRCodeDialog.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Button, Modal, ModalDialog } from "@mui/joy";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shortcut: Shortcut;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||||
|
const { shortcut, onClose } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
|
const handleCloseBtnClick = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQRCodeClick = () => {
|
||||||
|
const canvas = containerRef.current?.querySelector("canvas");
|
||||||
|
if (!canvas) {
|
||||||
|
toast.error("Failed to get qr code canvas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.download = "filename.png";
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
handleCloseBtnClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true}>
|
||||||
|
<ModalDialog>
|
||||||
|
<div className="flex flex-row justify-between items-center w-64 mb-4">
|
||||||
|
<span className="text-lg font-medium">QR Code</span>
|
||||||
|
<Button variant="plain" onClick={handleCloseBtnClick}>
|
||||||
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div ref={containerRef} className="w-full flex flex-row justify-center items-center mt-2 mb-6">
|
||||||
|
<QRCodeCanvas value={shortcutLink} size={128} bgColor={"#ffffff"} fgColor={"#000000"} includeMargin={false} level={"L"} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-row justify-center items-center px-4">
|
||||||
|
<Button className="w-full" color="neutral" onClick={handleDownloadQRCodeClick}>
|
||||||
|
<Icon.Download className="w-4 h-auto mr-1" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateQRCodeDialog;
|
@ -1,28 +1,29 @@
|
|||||||
import { Avatar } from "@mui/joy";
|
import { Avatar } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAppSelector } from "../stores";
|
import * as api from "../helpers/api";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const user = useAppSelector((state) => state.user).user as User;
|
|
||||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleSignOutButtonClick = async () => {
|
const handleSignOutButtonClick = async () => {
|
||||||
navigate("/auth");
|
await api.signout();
|
||||||
|
window.location.href = "/auth";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full bg-amber-50">
|
<div className="w-full bg-gray-50 border-b border-b-gray-200">
|
||||||
<div className="w-full max-w-4xl mx-auto px-3 py-5 flex flex-row justify-between items-center">
|
<div className="w-full max-w-4xl mx-auto px-3 py-5 flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
<Link to="/" className="text-base font-mono font-medium cursor-pointer flex flex-row justify-start items-center">
|
<Link to="/" className="text-base font-mono font-medium cursor-pointer flex flex-row justify-start items-center">
|
||||||
<img src="/logo.png" className="w-8 h-auto mr-2" alt="" />
|
<img src="/logo.png" className="w-8 h-auto mr-2" alt="" />
|
||||||
Shortify
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@ -30,7 +31,7 @@ const Header: React.FC = () => {
|
|||||||
trigger={
|
trigger={
|
||||||
<button className="flex flex-row justify-end items-center cursor-pointer">
|
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||||
<Avatar size="sm" variant="plain" />
|
<Avatar size="sm" variant="plain" />
|
||||||
<span>{user.nickname}</span>
|
<span>{currentUser.nickname}</span>
|
||||||
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
|
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
54
web/src/components/OrderSetting.tsx
Normal file
54
web/src/components/OrderSetting.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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 OrderSetting = () => {
|
||||||
|
const viewStore = useViewStore();
|
||||||
|
const order = viewStore.getOrder();
|
||||||
|
const { field, direction } = order;
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
viewStore.setOrder({ field: "name", direction: "asc" });
|
||||||
|
toast.success("Order reset");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="p-1 mr-2">
|
||||||
|
<Icon.ListFilter className="w-5 h-auto text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderSetting;
|
@ -18,6 +18,8 @@ const ShortcutListView: React.FC<Props> = (props: Props) => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="w-full text-center text-gray-400 text-sm mt-2 mb-4 italic">Total {shortcutList.length} data</p>
|
||||||
|
|
||||||
{editingShortcutId && (
|
{editingShortcutId && (
|
||||||
<CreateShortcutDialog
|
<CreateShortcutDialog
|
||||||
shortcutId={editingShortcutId}
|
shortcutId={editingShortcutId}
|
||||||
|
@ -4,13 +4,16 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
import { useAppSelector } from "../stores";
|
|
||||||
import useFaviconStore from "../stores/v1/favicon";
|
import useFaviconStore from "../stores/v1/favicon";
|
||||||
|
import useViewStore from "../stores/v1/view";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
import { absolutifyLink } from "../helpers/utils";
|
import { absolutifyLink } from "../helpers/utils";
|
||||||
import { showCommonDialog } from "./Alert";
|
import { showCommonDialog } from "./Alert";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
import VisibilityIcon from "./VisibilityIcon";
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||||
|
import AnalyticsDialog from "./AnalyticsDialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
@ -20,11 +23,14 @@ interface Props {
|
|||||||
const ShortcutView = (props: Props) => {
|
const ShortcutView = (props: Props) => {
|
||||||
const { shortcut, handleEdit } = props;
|
const { shortcut, handleEdit } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useAppSelector((state) => state.user.user as User);
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const viewStore = useViewStore();
|
||||||
const faviconStore = useFaviconStore();
|
const faviconStore = useFaviconStore();
|
||||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||||
const havePermission = user.role === "ADMIN" || shortcut.creatorId === user.id;
|
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||||
const shortifyLink = absolutifyLink(`/s/${shortcut.name}`);
|
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
||||||
|
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||||
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||||
@ -35,14 +41,14 @@ const ShortcutView = (props: Props) => {
|
|||||||
}, [shortcut.link]);
|
}, [shortcut.link]);
|
||||||
|
|
||||||
const handleCopyButtonClick = () => {
|
const handleCopyButtonClick = () => {
|
||||||
copy(shortifyLink);
|
copy(shortcutLink);
|
||||||
toast.success("Shortcut link copied to clipboard.");
|
toast.success("Shortcut link copied to clipboard.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
|
||||||
showCommonDialog({
|
showCommonDialog({
|
||||||
title: "Delete Shortcut",
|
title: "Delete Shortcut",
|
||||||
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You can not undo this action.`,
|
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
|
||||||
style: "danger",
|
style: "danger",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await shortcutService.deleteShortcutById(shortcut.id);
|
await shortcutService.deleteShortcutById(shortcut.id);
|
||||||
@ -51,84 +57,126 @@ 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-row justify-between items-center">
|
<div className="w-full flex flex-col justify-start items-start border px-4 py-3 mb-2 rounded-lg hover:shadow">
|
||||||
<div className="group flex flex-row justify-start items-center mr-1 shrink-0">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<div className="w-6 h-6 mr-1.5 flex justify-center items-center overflow-clip">
|
<div className="group flex flex-row justify-start items-center pr-2 mr-1 shrink-0">
|
||||||
{favicon ? (
|
<div className="w-6 h-6 mr-1 flex justify-center items-center overflow-clip">
|
||||||
<img className="w-[90%] h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
{favicon ? (
|
||||||
) : (
|
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||||
<Icon.Globe2 className="w-5 h-auto text-gray-500" />
|
) : (
|
||||||
|
<Icon.CircleSlash className="w-6 h-auto text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow"
|
||||||
|
target="_blank"
|
||||||
|
href={shortcutLink}
|
||||||
|
>
|
||||||
|
<span className="text-gray-400">s/</span>
|
||||||
|
{shortcut.name}
|
||||||
|
<span className="hidden group-hover:block ml-1 cursor-pointer">
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto text-gray-600" />
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||||
|
<button
|
||||||
|
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow"
|
||||||
|
onClick={() => handleCopyButtonClick()}
|
||||||
|
>
|
||||||
|
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="QR Code" variant="solid" placement="top" arrow>
|
||||||
|
<button
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<Icon.QrCode className="w-4 h-auto mx-auto" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-end items-center space-x-2">
|
||||||
|
{havePermission && (
|
||||||
|
<Dropdown
|
||||||
|
actionsClassName="!w-32"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => handleEdit()}
|
||||||
|
>
|
||||||
|
<Icon.Edit className="w-4 h-auto mr-2" /> Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => setShowAnalyticsDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-2" /> Analytics
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteShortcutButtonClick(shortcut);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon.Trash className="w-4 h-auto mr-2" /> Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
></Dropdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="items-center cursor-pointer hover:opacity-80" onClick={() => handleCopyButtonClick()}>
|
|
||||||
<span className="text-gray-400">s/</span>
|
|
||||||
{shortcut.name}
|
|
||||||
</button>
|
|
||||||
<a className="hidden group-hover:block ml-1 cursor-pointer hover:opacity-80" target="_blank" href={shortifyLink}>
|
|
||||||
<Icon.ExternalLink className="w-4 h-auto text-gray-500" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row justify-end items-center space-x-2">
|
{shortcut.description && <p className="mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
|
||||||
{havePermission && (
|
{shortcut.tags.length > 0 && (
|
||||||
<Dropdown
|
<div className="mt-2 ml-1 flex flex-row justify-start items-start gap-2">
|
||||||
actionsClassName="!w-24"
|
<Icon.Tag className="text-gray-400 w-4 h-auto" />
|
||||||
actions={
|
{shortcut.tags.map((tag) => {
|
||||||
<>
|
return (
|
||||||
<button
|
<span
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
key={tag}
|
||||||
onClick={() => handleEdit()}
|
className="text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
||||||
>
|
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||||
<Icon.Edit className="w-4 h-auto mr-2" /> Edit
|
>
|
||||||
</button>
|
#{tag}
|
||||||
<button
|
</span>
|
||||||
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
);
|
||||||
onClick={() => {
|
})}
|
||||||
handleDeleteShortcutButtonClick(shortcut);
|
</div>
|
||||||
}}
|
)}
|
||||||
>
|
<div className="w-full flex mt-2 gap-2">
|
||||||
<Icon.Trash className="w-4 h-auto mr-2" /> Delete
|
<Tooltip title="Creator" variant="solid" placement="top" arrow>
|
||||||
</button>
|
<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" />
|
||||||
}
|
{shortcut.creator.nickname}
|
||||||
></Dropdown>
|
</div>
|
||||||
)}
|
</Tooltip>
|
||||||
|
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||||
|
<div
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
||||||
|
onClick={() => viewStore.setFilter({ visibility: shortcut.visibility })}
|
||||||
|
>
|
||||||
|
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
||||||
|
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
||||||
|
<div
|
||||||
|
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full cursor-pointer text-gray-500 text-sm"
|
||||||
|
onClick={() => setShowAnalyticsDialog(true)}
|
||||||
|
>
|
||||||
|
<Icon.BarChart2 className="w-4 h-auto mr-1" />
|
||||||
|
{shortcut.view} visits
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shortcut.description && <p className="mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
|
|
||||||
{shortcut.tags.length > 0 && (
|
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||||
<div className="mt-1 flex flex-row justify-start items-start gap-2">
|
|
||||||
<Icon.Tag className="text-gray-400 w-4 h-auto" />
|
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
||||||
{shortcut.tags.map((tag) => {
|
</>
|
||||||
return (
|
|
||||||
<span key={tag} className="text-gray-400 text-sm font-mono leading-4">
|
|
||||||
#{tag}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="w-full flex mt-2 gap-2">
|
|
||||||
<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">
|
|
||||||
<Icon.User className="w-4 h-auto mr-1" />
|
|
||||||
{shortcut.creator.nickname}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
|
||||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
|
||||||
<VisibilityIcon className="w-4 h-auto mr-1" visibility={shortcut.visibility} />
|
|
||||||
{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="View count" variant="solid" placement="top" arrow>
|
|
||||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
|
||||||
<Icon.Eye className="w-4 h-auto mr-1" />
|
|
||||||
{shortcut.view} visits
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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]);
|
||||||
|
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAppSelector } from "../../stores";
|
import useUserStore from "../../stores/v1/user";
|
||||||
import ChangePasswordDialog from "../ChangePasswordDialog";
|
import ChangePasswordDialog from "../ChangePasswordDialog";
|
||||||
import EditUserinfoDialog from "../EditUserinfoDialog";
|
import EditUserinfoDialog from "../EditUserinfoDialog";
|
||||||
|
|
||||||
const AccountSection: React.FC = () => {
|
const AccountSection: React.FC = () => {
|
||||||
const user = useAppSelector((state) => state.user).user as User;
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
||||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
||||||
const isAdmin = user.role === "ADMIN";
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start gap-y-2">
|
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start gap-y-2">
|
||||||
<p className="text-gray-400">Account</p>
|
<p className="text-base font-semibold leading-6 text-gray-900">Account</p>
|
||||||
<p className="flex flex-row justify-start items-center mt-2">
|
<p className="flex flex-row justify-start items-center mt-2">
|
||||||
<span className="text-xl font-medium">{user.nickname}</span>
|
<span className="text-xl">{currentUser.nickname}</span>
|
||||||
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
|
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex flex-row justify-start items-center">
|
<p className="flex flex-row justify-start items-center">
|
||||||
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
||||||
{user.email}
|
{currentUser.email}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-row justify-start items-center gap-2 mt-2">
|
<div className="flex flex-row justify-start items-center gap-2 mt-2">
|
||||||
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
|
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
|
||||||
|
95
web/src/components/setting/UserSection.tsx
Normal file
95
web/src/components/setting/UserSection.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@mui/joy";
|
||||||
|
import CreateUserDialog from "../CreateUserDialog";
|
||||||
|
import useUserStore from "../../stores/v1/user";
|
||||||
|
|
||||||
|
const MemberSection = () => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [showCreateUserDialog, setShowCreateUserDialog] = useState<boolean>(false);
|
||||||
|
const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined);
|
||||||
|
const userList = Object.values(userStore.userMap);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userStore.fetchUserList();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateUserDialogClose = () => {
|
||||||
|
setShowCreateUserDialog(false);
|
||||||
|
setCurrentEditingUser(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="sm:flex sm:items-center">
|
||||||
|
<div className="sm:flex-auto">
|
||||||
|
<p className="text-base font-semibold leading-6 text-gray-900">Users</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-700">
|
||||||
|
A list of all the users in your workspace including their nickname, email and role.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateUserDialog(true);
|
||||||
|
setCurrentEditingUser(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flow-root">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full py-2 align-middle">
|
||||||
|
<table className="min-w-full divide-y divide-gray-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">
|
||||||
|
Nickname
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="relative py-3.5 pl-3 pr-4">
|
||||||
|
<span className="sr-only">Edit</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{userList.map((user) => (
|
||||||
|
<tr key={user.email}>
|
||||||
|
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900">{user.nickname}</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td>
|
||||||
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
|
||||||
|
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium">
|
||||||
|
<button
|
||||||
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentEditingUser(user);
|
||||||
|
setShowCreateUserDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateUserDialog && <CreateUserDialog user={currentEditingUser} onClose={handleCreateUserDialogClose} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MemberSection;
|
@ -17,20 +17,17 @@ const WorkspaceSection: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
<p className="text-base font-semibold leading-6 text-gray-900">Workspace settings</p>
|
||||||
<p className="text-gray-400">Workspace settings</p>
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<Checkbox
|
||||||
<Checkbox
|
label="Disable user signup"
|
||||||
className="font-medium"
|
checked={disallowSignUp}
|
||||||
label="Disable self-service signup"
|
onChange={(event) => handleDisallowSignUpChange(event.target.checked)}
|
||||||
checked={disallowSignUp}
|
/>
|
||||||
onChange={(event) => handleDisallowSignUpChange(event.target.checked)}
|
<p className="mt-2 text-gray-500">Once disabled, other users cannot signup.</p>
|
||||||
/>
|
|
||||||
<p className="mt-2 text-gray-500">Once disabled, other users cannot signup.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -35,6 +35,10 @@ export function getUserById(id: number) {
|
|||||||
return axios.get<User>(`/api/v1/user/${id}`);
|
return axios.get<User>(`/api/v1/user/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createUser(userCreate: UserCreate) {
|
||||||
|
return axios.post<User>("/api/v1/user", userCreate);
|
||||||
|
}
|
||||||
|
|
||||||
export function patchUser(userPatch: UserPatch) {
|
export function patchUser(userPatch: UserPatch) {
|
||||||
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
|
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
|
||||||
}
|
}
|
||||||
@ -45,9 +49,6 @@ export function deleteUser(userDelete: UserDelete) {
|
|||||||
|
|
||||||
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||||
const queryList = [];
|
const queryList = [];
|
||||||
if (shortcutFind?.creatorId) {
|
|
||||||
queryList.push(`creatorId=${shortcutFind.creatorId}`);
|
|
||||||
}
|
|
||||||
if (shortcutFind?.tag) {
|
if (shortcutFind?.tag) {
|
||||||
queryList.push(`tag=${shortcutFind.tag}`);
|
queryList.push(`tag=${shortcutFind.tag}`);
|
||||||
}
|
}
|
||||||
@ -58,6 +59,10 @@ export function createShortcut(shortcutCreate: ShortcutCreate) {
|
|||||||
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
|
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShortcutAnalytics(shortcutId: ShortcutId) {
|
||||||
|
return axios.get<AnalysisData>(`/api/v1/shortcut/${shortcutId}/analytics`);
|
||||||
|
}
|
||||||
|
|
||||||
export function patchShortcut(shortcutPatch: ShortcutPatch) {
|
export function patchShortcut(shortcutPatch: ShortcutPatch) {
|
||||||
return axios.patch<Shortcut>(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch);
|
return axios.patch<Shortcut>(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,31 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { useEffect } from "react";
|
||||||
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
|
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 currentUser = useUserStore().getCurrentUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) {
|
||||||
|
navigate("/auth", {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col justify-start items-start">
|
<>
|
||||||
<Header />
|
{currentUser && (
|
||||||
<Outlet />
|
<div className="w-full h-full flex flex-col justify-start items-start">
|
||||||
</div>
|
<DemoBanner />
|
||||||
|
<Header />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,15 +3,15 @@
|
|||||||
"visibility": {
|
"visibility": {
|
||||||
"private": {
|
"private": {
|
||||||
"self": "Private",
|
"self": "Private",
|
||||||
"description": "Only you can see this"
|
"description": "Only you can access"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"self": "Workspace",
|
"self": "Workspace",
|
||||||
"description": "Only people in your workspace can see this"
|
"description": "Workspace members can access"
|
||||||
},
|
},
|
||||||
"public": {
|
"public": {
|
||||||
"self": "Public",
|
"self": "Public",
|
||||||
"description": "Anyone can see this"
|
"description": "Available to Everyone on the Internet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
|
import { CssVarsProvider } from "@mui/joy";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
import store from "./stores";
|
import store from "./stores";
|
||||||
import App from "./App";
|
import router from "./routers";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./css/index.css";
|
import "./css/index.css";
|
||||||
|
|
||||||
const container = document.getElementById("root");
|
const container = document.getElementById("root");
|
||||||
const root = createRoot(container as HTMLElement);
|
const root = createRoot(container as HTMLElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<App />
|
<CssVarsProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</CssVarsProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { Button } from "@mui/joy";
|
import { Button } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useAppSelector } from "../stores";
|
import useUserStore from "../stores/v1/user";
|
||||||
import ChangePasswordDialog from "../components/ChangePasswordDialog";
|
import ChangePasswordDialog from "../components/ChangePasswordDialog";
|
||||||
import EditUserinfoDialog from "../components/EditUserinfoDialog";
|
import EditUserinfoDialog from "../components/EditUserinfoDialog";
|
||||||
|
|
||||||
const Account: React.FC = () => {
|
const Account: React.FC = () => {
|
||||||
const user = useAppSelector((state) => state.user).user as User;
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
||||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||||
<p className="text-3xl my-2">{user.nickname}</p>
|
<p className="text-3xl my-2">{currentUser.nickname}</p>
|
||||||
<p className="leading-8 flex flex-row justify-start items-center">
|
<p className="leading-8 flex flex-row justify-start items-center">
|
||||||
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
||||||
{user.email}
|
{currentUser.email}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-row justify-start items-center gap-2">
|
<div className="flex flex-row justify-start items-center gap-2">
|
||||||
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
|
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
|
||||||
|
@ -2,10 +2,14 @@ import { Button, Tab, TabList, Tabs } 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, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||||
|
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 ShortcutListView from "../components/ShortcutListView";
|
||||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||||
|
import FilterView from "../components/FilterView";
|
||||||
|
import OrderSetting from "../components/OrderSetting";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
showCreateShortcutDialog: boolean;
|
showCreateShortcutDialog: boolean;
|
||||||
@ -13,13 +17,15 @@ interface State {
|
|||||||
|
|
||||||
const Home: React.FC = () => {
|
const Home: React.FC = () => {
|
||||||
const loadingState = useLoading();
|
const loadingState = useLoading();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const viewStore = useViewStore();
|
||||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||||
const user = useAppSelector((state) => state.user).user as User;
|
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
showCreateShortcutDialog: false,
|
showCreateShortcutDialog: false,
|
||||||
});
|
});
|
||||||
const [selectedFilter, setSelectFilter] = useState<"ALL" | "PRIVATE">("ALL");
|
const filter = viewStore.filter;
|
||||||
const filteredShortcutList = selectedFilter === "ALL" ? shortcutList : shortcutList.filter((shortcut) => shortcut.creatorId === user.id);
|
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||||
|
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
||||||
@ -42,34 +48,39 @@ const Home: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
<Tabs defaultValue={"ALL"} size="sm" onChange={(_, value) => setSelectFilter(value as any)}>
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||||
|
<Icon.Plus className="w-5 h-auto" /> New
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-end items-center">
|
||||||
|
<OrderSetting />
|
||||||
|
<Tabs
|
||||||
|
value={filter.mineOnly ? "PRIVATE" : "ALL"}
|
||||||
|
size="sm"
|
||||||
|
onChange={(_, value) => viewStore.setFilter({ mineOnly: value !== "ALL" })}
|
||||||
|
>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={"ALL"}>All</Tab>
|
<Tab value={"ALL"}>All</Tab>
|
||||||
<Tab value={"PRIVATE"}>Mine</Tab>
|
<Tab value={"PRIVATE"}>Mine</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Button variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
|
||||||
<Icon.Plus className="w-5 h-auto" /> New
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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-4 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-12 h-auto text-gray-400" />
|
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
||||||
<p className="mt-4 mb-2">No shortcuts found.</p>
|
<p className="mt-4">No shortcuts found.</p>
|
||||||
<Button size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
|
||||||
Create one
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ShortcutListView shortcutList={filteredShortcutList} />
|
<ShortcutListView shortcutList={orderedShortcutList} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
import { useAppSelector } from "../stores";
|
import useUserStore from "../stores/v1/user";
|
||||||
import AccountSection from "../components/setting/AccountSection";
|
import AccountSection from "../components/setting/AccountSection";
|
||||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||||
|
import UserSection from "../components/setting/UserSection";
|
||||||
|
|
||||||
const Setting: React.FC = () => {
|
const Setting: React.FC = () => {
|
||||||
const user = useAppSelector((state) => state.user).user as User;
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const isAdmin = user.role === "ADMIN";
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||||
<AccountSection />
|
<AccountSection />
|
||||||
{isAdmin && <WorkspaceSection />}
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<UserSection />
|
||||||
|
<WorkspaceSection />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,14 +3,18 @@ import React, { 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";
|
||||||
import { userService } from "../services";
|
|
||||||
import { useAppSelector } from "../stores";
|
import { useAppSelector } from "../stores";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
const SignIn: React.FC = () => {
|
const SignIn: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const userStore = useUserStore();
|
||||||
const {
|
const {
|
||||||
workspaceProfile: { disallowSignUp },
|
workspaceProfile: {
|
||||||
|
disallowSignUp,
|
||||||
|
profile: { mode },
|
||||||
|
},
|
||||||
} = useAppSelector((state) => state.global);
|
} = useAppSelector((state) => state.global);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -18,7 +22,16 @@ const SignIn: React.FC = () => {
|
|||||||
const allowConfirm = email.length > 0 && password.length > 0;
|
const allowConfirm = email.length > 0 && password.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
userService.doSignOut();
|
if (userStore.getCurrentUser()) {
|
||||||
|
return navigate("/", {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "demo") {
|
||||||
|
setEmail("slash@stevenlgtm.com");
|
||||||
|
setPassword("secret");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -39,7 +52,7 @@ const SignIn: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
actionBtnLoadingState.setLoading();
|
actionBtnLoadingState.setLoading();
|
||||||
await api.signin(email, password);
|
await api.signin(email, password);
|
||||||
const user = await userService.doSignIn();
|
const user = await userStore.fetchCurrentUser();
|
||||||
if (user) {
|
if (user) {
|
||||||
navigate("/", {
|
navigate("/", {
|
||||||
replace: true,
|
replace: true,
|
||||||
@ -60,7 +73,7 @@ const SignIn: React.FC = () => {
|
|||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
|
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
|
||||||
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
|
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
|
||||||
<span className="text-2xl font-medium font-mono opacity-80">Shortify</span>
|
<span className="text-2xl font-medium font-mono opacity-80">Slash</span>
|
||||||
</div>
|
</div>
|
||||||
<form className="w-full" onSubmit={handleSigninBtnClick}>
|
<form className="w-full" onSubmit={handleSigninBtnClick}>
|
||||||
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||||
@ -70,7 +83,7 @@ const SignIn: React.FC = () => {
|
|||||||
className="w-full py-3"
|
className="w-full py-3"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
placeholder="steven@shortify.com"
|
placeholder="steven@slash.com"
|
||||||
onChange={handleEmailInputChanged}
|
onChange={handleEmailInputChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,11 +3,16 @@ import React, { 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";
|
||||||
import { userService } from "../services";
|
import { globalService } from "../services";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
const SignUp: React.FC = () => {
|
const SignUp: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const {
|
||||||
|
workspaceProfile: { disallowSignUp },
|
||||||
|
} = globalService.getState();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [nickname, setNickname] = useState("");
|
const [nickname, setNickname] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -15,7 +20,17 @@ const SignUp: React.FC = () => {
|
|||||||
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
|
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
userService.doSignOut();
|
if (userStore.getCurrentUser()) {
|
||||||
|
return navigate("/", {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disallowSignUp) {
|
||||||
|
return navigate("/auth", {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -41,7 +56,7 @@ const SignUp: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
actionBtnLoadingState.setLoading();
|
actionBtnLoadingState.setLoading();
|
||||||
await api.signup(email, nickname, password);
|
await api.signup(email, nickname, password);
|
||||||
const user = await userService.doSignIn();
|
const user = await userStore.fetchCurrentUser();
|
||||||
if (user) {
|
if (user) {
|
||||||
navigate("/", {
|
navigate("/", {
|
||||||
replace: true,
|
replace: true,
|
||||||
@ -62,7 +77,7 @@ const SignUp: React.FC = () => {
|
|||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
|
<div className="flex flex-col justify-start items-center w-full gap-y-2 mb-4">
|
||||||
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
|
<img src="/logo.png" className="w-16 h-auto" alt="logo" />
|
||||||
<span className="text-2xl font-medium font-mono opacity-80">Shortify</span>
|
<span className="text-2xl font-medium font-mono opacity-80">Slash</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="w-full text-center mb-4 text-2xl">Create your account</p>
|
<p className="w-full text-center mb-4 text-2xl">Create your account</p>
|
||||||
<form className="w-full" onSubmit={handleSignupBtnClick}>
|
<form className="w-full" onSubmit={handleSignupBtnClick}>
|
||||||
@ -73,7 +88,7 @@ const SignUp: React.FC = () => {
|
|||||||
className="w-full py-3"
|
className="w-full py-3"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
placeholder="steven@shortify.com"
|
placeholder="steven@slash.com"
|
||||||
onChange={handleEmailInputChanged}
|
onChange={handleEmailInputChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,83 +1,37 @@
|
|||||||
import { createBrowserRouter, redirect } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
import { isNullorUndefined } from "../helpers/utils";
|
|
||||||
import { globalService, userService } from "../services";
|
|
||||||
import Root from "../layouts/Root";
|
import Root from "../layouts/Root";
|
||||||
import SignIn from "../pages/SignIn";
|
import SignIn from "../pages/SignIn";
|
||||||
import SignUp from "../pages/SignUp";
|
import SignUp from "../pages/SignUp";
|
||||||
import Home from "../pages/Home";
|
import Home from "../pages/Home";
|
||||||
import Setting from "../pages/Setting";
|
import Setting from "../pages/Setting";
|
||||||
|
import App from "../App";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
|
||||||
path: "/auth",
|
|
||||||
element: <SignIn />,
|
|
||||||
loader: async () => {
|
|
||||||
try {
|
|
||||||
await globalService.initialState();
|
|
||||||
} catch (error) {
|
|
||||||
// do nth
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/auth/signup",
|
|
||||||
element: <SignUp />,
|
|
||||||
loader: async () => {
|
|
||||||
try {
|
|
||||||
await globalService.initialState();
|
|
||||||
} catch (error) {
|
|
||||||
// do nth
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
workspaceProfile: { disallowSignUp },
|
|
||||||
} = globalService.getState();
|
|
||||||
if (disallowSignUp) {
|
|
||||||
return redirect("/auth");
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Root />,
|
element: <App />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "auth",
|
||||||
element: <Home />,
|
element: <SignIn />,
|
||||||
loader: async () => {
|
|
||||||
try {
|
|
||||||
await userService.initialState();
|
|
||||||
} catch (error) {
|
|
||||||
// do nth
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = userService.getState();
|
|
||||||
if (isNullorUndefined(user)) {
|
|
||||||
return redirect("/auth");
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/setting",
|
path: "auth/signup",
|
||||||
element: <Setting />,
|
element: <SignUp />,
|
||||||
loader: async () => {
|
},
|
||||||
try {
|
{
|
||||||
await userService.initialState();
|
path: "",
|
||||||
} catch (error) {
|
element: <Root />,
|
||||||
// do nth
|
children: [
|
||||||
}
|
{
|
||||||
|
path: "",
|
||||||
const { user } = userService.getState();
|
element: <Home />,
|
||||||
if (isNullorUndefined(user)) {
|
},
|
||||||
return redirect("/auth");
|
{
|
||||||
}
|
path: "/setting",
|
||||||
return null;
|
element: <Setting />,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
import store from "../stores";
|
import store from "../stores";
|
||||||
import { setGlobalState } from "../stores/modules/global";
|
import { setGlobalState } from "../stores/modules/global";
|
||||||
import userService from "./userService";
|
|
||||||
|
|
||||||
const globalService = {
|
const globalService = {
|
||||||
getState: () => {
|
getState: () => {
|
||||||
@ -15,12 +14,6 @@ const globalService = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// do nth
|
// do nth
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await userService.initialState();
|
|
||||||
} catch (error) {
|
|
||||||
// do nth
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import globalService from "./globalService";
|
import globalService from "./globalService";
|
||||||
import shortcutService from "./shortcutService";
|
import shortcutService from "./shortcutService";
|
||||||
import userService from "./userService";
|
|
||||||
|
|
||||||
export { globalService, shortcutService, userService };
|
export { globalService, shortcutService };
|
||||||
|
@ -1,66 +0,0 @@
|
|||||||
import * as api from "../helpers/api";
|
|
||||||
import store from "../stores";
|
|
||||||
import { setUser, patchUser } from "../stores/modules/user";
|
|
||||||
|
|
||||||
export const convertResponseModelUser = (user: User): User => {
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
createdTs: user.createdTs * 1000,
|
|
||||||
updatedTs: user.updatedTs * 1000,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const userService = {
|
|
||||||
getState: () => {
|
|
||||||
return store.getState().user;
|
|
||||||
},
|
|
||||||
|
|
||||||
initialState: async () => {
|
|
||||||
try {
|
|
||||||
const user = (await api.getMyselfUser()).data;
|
|
||||||
if (user) {
|
|
||||||
store.dispatch(setUser(convertResponseModelUser(user)));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// do nth
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
doSignIn: async () => {
|
|
||||||
const user = (await api.getMyselfUser()).data;
|
|
||||||
if (user) {
|
|
||||||
store.dispatch(setUser(convertResponseModelUser(user)));
|
|
||||||
} else {
|
|
||||||
userService.doSignOut();
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
},
|
|
||||||
|
|
||||||
doSignOut: async () => {
|
|
||||||
store.dispatch(setUser());
|
|
||||||
await api.signout();
|
|
||||||
},
|
|
||||||
|
|
||||||
getUserById: async (userId: UserId) => {
|
|
||||||
const user = (await api.getUserById(userId)).data;
|
|
||||||
if (user) {
|
|
||||||
return convertResponseModelUser(user);
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
patchUser: async (userPatch: UserPatch): Promise<void> => {
|
|
||||||
const data = (await api.patchUser(userPatch)).data;
|
|
||||||
if (userPatch.id === store.getState().user.user?.id) {
|
|
||||||
const user = convertResponseModelUser(data);
|
|
||||||
store.dispatch(patchUser(user));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteUser: async (userDelete: UserDelete) => {
|
|
||||||
await api.deleteUser(userDelete);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default userService;
|
|
@ -1,13 +1,11 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import { TypedUseSelectorHook, useSelector } from "react-redux";
|
import { TypedUseSelectorHook, useSelector } from "react-redux";
|
||||||
import globalReducer from "./modules/global";
|
import globalReducer from "./modules/global";
|
||||||
import userReducer from "./modules/user";
|
|
||||||
import shortcutReducer from "./modules/shortcut";
|
import shortcutReducer from "./modules/shortcut";
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
global: globalReducer,
|
global: globalReducer,
|
||||||
user: userReducer,
|
|
||||||
shortcut: shortcutReducer,
|
shortcut: shortcutReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSlice = createSlice({
|
|
||||||
name: "user",
|
|
||||||
initialState: {} as State,
|
|
||||||
reducers: {
|
|
||||||
setUser: (state, action: PayloadAction<User | undefined>) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: action.payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
patchUser: (state, action: PayloadAction<Partial<User>>) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: {
|
|
||||||
...state.user,
|
|
||||||
...action.payload,
|
|
||||||
} as User,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { setUser, patchUser } = userSlice.actions;
|
|
||||||
|
|
||||||
export default userSlice.reducer;
|
|
83
web/src/stores/v1/user.ts
Normal file
83
web/src/stores/v1/user.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import * as api from "../../helpers/api";
|
||||||
|
|
||||||
|
const convertResponseModelUser = (user: User): User => {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
createdTs: user.createdTs * 1000,
|
||||||
|
updatedTs: user.updatedTs * 1000,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
userMap: {
|
||||||
|
[key: UserId]: User;
|
||||||
|
};
|
||||||
|
currentUserId?: UserId;
|
||||||
|
fetchUserList: () => Promise<User[]>;
|
||||||
|
fetchCurrentUser: () => Promise<User>;
|
||||||
|
getOrFetchUserById: (id: UserId) => Promise<User>;
|
||||||
|
getUserById: (id: UserId) => User;
|
||||||
|
getCurrentUser: () => User;
|
||||||
|
createUser: (userCreate: UserCreate) => Promise<User>;
|
||||||
|
patchUser: (userPatch: UserPatch) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useUserStore = create<UserState>()((set, get) => ({
|
||||||
|
userMap: {},
|
||||||
|
fetchUserList: async () => {
|
||||||
|
const { data: userList } = await api.getUserList();
|
||||||
|
const userMap = get().userMap;
|
||||||
|
userList.forEach((user) => {
|
||||||
|
userMap[user.id] = convertResponseModelUser(user);
|
||||||
|
});
|
||||||
|
set(userMap);
|
||||||
|
return userList;
|
||||||
|
},
|
||||||
|
fetchCurrentUser: async () => {
|
||||||
|
const { data } = await api.getMyselfUser();
|
||||||
|
const user = convertResponseModelUser(data);
|
||||||
|
const userMap = get().userMap;
|
||||||
|
userMap[user.id] = user;
|
||||||
|
set({ userMap, currentUserId: user.id });
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
getOrFetchUserById: async (id: UserId) => {
|
||||||
|
const userMap = get().userMap;
|
||||||
|
if (userMap[id]) {
|
||||||
|
return userMap[id] as User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.getUserById(id);
|
||||||
|
const user = convertResponseModelUser(data);
|
||||||
|
userMap[id] = user;
|
||||||
|
set(userMap);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
createUser: async (userCreate: UserCreate) => {
|
||||||
|
const { data } = await api.createUser(userCreate);
|
||||||
|
const user = convertResponseModelUser(data);
|
||||||
|
const userMap = get().userMap;
|
||||||
|
userMap[user.id] = user;
|
||||||
|
set(userMap);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
patchUser: async (userPatch: UserPatch) => {
|
||||||
|
const { data } = await api.patchUser(userPatch);
|
||||||
|
const user = convertResponseModelUser(data);
|
||||||
|
const userMap = get().userMap;
|
||||||
|
userMap[user.id] = user;
|
||||||
|
set(userMap);
|
||||||
|
},
|
||||||
|
getUserById: (id: UserId) => {
|
||||||
|
const userMap = get().userMap;
|
||||||
|
return userMap[id] as User;
|
||||||
|
},
|
||||||
|
getCurrentUser: () => {
|
||||||
|
const userMap = get().userMap;
|
||||||
|
const currentUserId = get().currentUserId;
|
||||||
|
return userMap[currentUserId as UserId];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useUserStore;
|
94
web/src/stores/v1/view.ts
Normal file
94
web/src/stores/v1/view.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
tag?: string;
|
||||||
|
mineOnly?: boolean;
|
||||||
|
visibility?: Visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
field: "name" | "createdTs" | "updatedTs" | "view";
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewState {
|
||||||
|
filter: Filter;
|
||||||
|
order: Order;
|
||||||
|
setFilter: (filter: Partial<Filter>) => void;
|
||||||
|
getOrder: () => Order;
|
||||||
|
setOrder: (order: Partial<Order>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useViewStore = create<ViewState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
filter: {},
|
||||||
|
order: {
|
||||||
|
field: "name",
|
||||||
|
direction: "asc",
|
||||||
|
},
|
||||||
|
setFilter: (filter: Partial<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 } });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "view",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
20
web/src/types/analytics.d.ts
vendored
Normal file
20
web/src/types/analytics.d.ts
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
interface ReferenceInfo {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInfo {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowserInfo {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisData {
|
||||||
|
referenceData: ReferenceInfo[];
|
||||||
|
deviceData: DeviceInfo[];
|
||||||
|
browserData: BrowserInfo[];
|
||||||
|
}
|
10
web/src/types/modules/shortcut.d.ts
vendored
10
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,9 +43,9 @@ interface ShortcutPatch {
|
|||||||
description?: string;
|
description?: string;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
openGraphMetadata?: OpenGraphMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortcutFind {
|
interface ShortcutFind {
|
||||||
creatorId?: UserId;
|
|
||||||
tag?: string;
|
tag?: string;
|
||||||
}
|
}
|
||||||
|
8
web/src/types/modules/user.d.ts
vendored
8
web/src/types/modules/user.d.ts
vendored
@ -14,6 +14,13 @@ interface User {
|
|||||||
role: Role;
|
role: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserCreate {
|
||||||
|
email: string;
|
||||||
|
nickname: string;
|
||||||
|
password: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
interface UserPatch {
|
interface UserPatch {
|
||||||
id: UserId;
|
id: UserId;
|
||||||
|
|
||||||
@ -21,6 +28,7 @@ interface UserPatch {
|
|||||||
email?: string;
|
email?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
role?: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserDelete {
|
interface UserDelete {
|
||||||
|
Reference in New Issue
Block a user