mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 13:12:36 +00:00
Compare commits
66 Commits
Author | SHA1 | Date | |
---|---|---|---|
d42d3fbe10 | |||
6dfccb9509 | |||
66876452e1 | |||
6b107924aa | |||
b84620c057 | |||
c30b6adb8e | |||
c8fea442d6 | |||
a36a99e53d | |||
86078b097d | |||
11205566ac | |||
709118464b | |||
792b60c480 | |||
1418fc2209 | |||
53c1d8fa91 | |||
b32fdbfc0a | |||
db2aebcf57 | |||
b4e23fc8a0 | |||
7ab66113ac | |||
2909676ed3 | |||
5af9236c19 | |||
04c0f47559 | |||
a91997683b | |||
014dd7d660 | |||
a1b633e4db | |||
57496c9b46 | |||
c4f38f1de6 | |||
e7cf0c2f79 | |||
15ffd0738c | |||
21ff8ba797 | |||
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
|
||||
attributes:
|
||||
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
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
|
@ -41,4 +41,4 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: stevenlgtm/shortify:test
|
||||
tags: stevenlgtm/slash:test
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -11,5 +11,3 @@ web/dist
|
||||
build
|
||||
|
||||
.DS_Store
|
||||
|
||||
extension
|
||||
|
16
Dockerfile
16
Dockerfile
@ -17,18 +17,24 @@ WORKDIR /backend-build
|
||||
COPY . .
|
||||
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.
|
||||
FROM alpine:3.16 AS monolithic
|
||||
WORKDIR /usr/local/shortify
|
||||
WORKDIR /usr/local/slash
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
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.
|
||||
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"]
|
||||
|
25
README.md
25
README.md
@ -1,17 +1,28 @@
|
||||
# Shortify
|
||||
# Slash
|
||||
|
||||
<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 link shortening service that enables easy saving and sharing of links. It allows you to store, categorize, and share links with custom short URLs. You can search, filter, and access your saved links from any device. It also supports team sharing of link libraries for easy collaboration.
|
||||
|
||||
Let's Simplify, Share, and Save your links with **Shortify**.
|
||||
Try it out on <a href="https://slash.stevenlgtm.com">Live Demo</a>.
|
||||
|
||||
<p>
|
||||
<a href="https://discord.gg/QZqUuUAhDV"><img alt="Discord" src="https://img.shields.io/badge/discord-chat-5865f2?logo=discord&logoColor=f5f5f5" /></a>
|
||||
<a href="https://hub.docker.com/r/stevenlgtm/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/stevenlgtm/slash.svg" /></a>
|
||||
<a href="https://github.com/boojack/slash/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/boojack/slash?logo=github" /></a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- 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
|
||||
|
||||
```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
|
||||
|
||||

|
||||
Learn more in [Self-hosting Slash with Docker](https://github.com/boojack/slash/blob/main/docs/install.md).
|
||||
|
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"
|
||||
"net/http"
|
||||
|
||||
"github.com/boojack/shortify/server/auth"
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/api/v1/auth"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
@ -5,14 +5,14 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "shortify"
|
||||
issuer = "slash"
|
||||
// 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.
|
||||
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.
|
||||
CookieExpDuration = refreshTokenDuration - 1*time.Minute
|
||||
// AccessTokenCookieName is the cookie name of access token.
|
||||
AccessTokenCookieName = "access-token"
|
||||
AccessTokenCookieName = "slash.access-token"
|
||||
// RefreshTokenCookieName is the cookie name of refresh token.
|
||||
RefreshTokenCookieName = "refresh-token"
|
||||
// UserIDCookieName is the cookie name of user ID.
|
||||
UserIDCookieName = "user"
|
||||
RefreshTokenCookieName = "slash.refresh-token"
|
||||
)
|
||||
|
||||
type claimsMessage struct {
|
@ -7,9 +7,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boojack/shortify/internal/util"
|
||||
"github.com/boojack/shortify/server/auth"
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/api/v1/auth"
|
||||
"github.com/boojack/slash/internal/util"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
@ -77,16 +77,18 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
path := c.Path()
|
||||
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)
|
||||
}
|
||||
|
||||
token := findAccessToken(c)
|
||||
if token == "" {
|
||||
// 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)
|
||||
}
|
||||
auth.RemoveTokensAndCookies(c)
|
||||
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"])
|
||||
})
|
||||
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
|
||||
if err != nil {
|
||||
var ve *jwt.ValidationError
|
||||
@ -115,10 +115,15 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
generateToken = true
|
||||
}
|
||||
} else {
|
||||
auth.RemoveTokensAndCookies(c)
|
||||
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
|
||||
ctx := c.Request().Context()
|
||||
userID, err := strconv.Atoi(claims.Subject)
|
||||
@ -195,8 +200,3 @@ func JWTMiddleware(server *APIV1Service, next echo.HandlerFunc, secret string) e
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultAuthSkipper(c echo.Context) bool {
|
||||
path := c.Path()
|
||||
return util.HasPrefixes(path, "/api/v1/auth")
|
||||
}
|
||||
|
@ -3,10 +3,12 @@ package v1
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -42,11 +44,44 @@ func (s *APIV1Service) registerRedirectorRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create activity, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
if isValidURLString(shortcut.Link) {
|
||||
return redirectToShortcut(c, shortcut)
|
||||
})
|
||||
}
|
||||
|
||||
func redirectToShortcut(c echo.Context, shortcut *store.Shortcut) error {
|
||||
isValidURL := isValidURLString(shortcut.Link)
|
||||
if shortcut.OpenGraphMetadata == nil || (shortcut.OpenGraphMetadata.Title == "" && shortcut.OpenGraphMetadata.Description == "" && shortcut.OpenGraphMetadata.Image == "") {
|
||||
if isValidURL {
|
||||
return c.Redirect(http.StatusSeeOther, shortcut.Link)
|
||||
}
|
||||
return c.String(http.StatusOK, shortcut.Link)
|
||||
})
|
||||
}
|
||||
|
||||
htmlTemplate := `<html><head>%s</head><body>%s</body></html>`
|
||||
metadataList := []string{
|
||||
fmt.Sprintf(`<title>%s</title>`, shortcut.OpenGraphMetadata.Title),
|
||||
fmt.Sprintf(`<meta name="description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
|
||||
fmt.Sprintf(`<meta property="og:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
|
||||
fmt.Sprintf(`<meta property="og:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
|
||||
fmt.Sprintf(`<meta property="og:image" content="%s" />`, shortcut.OpenGraphMetadata.Image),
|
||||
`<meta property="og:type" content="website" />`,
|
||||
// Twitter related metadata.
|
||||
fmt.Sprintf(`<meta name="twitter:title" content="%s" />`, shortcut.OpenGraphMetadata.Title),
|
||||
fmt.Sprintf(`<meta name="twitter:description" content="%s" />`, shortcut.OpenGraphMetadata.Description),
|
||||
fmt.Sprintf(`<meta name="twitter:image" content="%s" />`, shortcut.OpenGraphMetadata.Image),
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
}
|
||||
if isValidURL {
|
||||
metadataList = append(metadataList, fmt.Sprintf(`<meta property="og:url" content="%s" />`, shortcut.Link))
|
||||
}
|
||||
body := ""
|
||||
if isValidURL {
|
||||
body = fmt.Sprintf(`<script>window.location.href = "%s";</script>`, shortcut.Link)
|
||||
} else {
|
||||
body = html.EscapeString(shortcut.Link)
|
||||
}
|
||||
htmlString := fmt.Sprintf(htmlTemplate, strings.Join(metadataList, ""), body)
|
||||
return c.HTML(http.StatusOK, htmlString)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createShortcutViewActivity(c echo.Context, shortcut *store.Shortcut) error {
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@ -30,6 +30,12 @@ func (v Visibility) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
type OpenGraphMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID int `json:"id"`
|
||||
|
||||
@ -47,6 +53,7 @@ type Shortcut struct {
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
View int `json:"view"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type CreateShortcutRequest struct {
|
||||
@ -55,6 +62,7 @@ type CreateShortcutRequest struct {
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type PatchShortcutRequest struct {
|
||||
@ -64,6 +72,7 @@ type PatchShortcutRequest struct {
|
||||
Description *string `json:"description"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
@ -83,8 +92,13 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
Name: strings.ToLower(create.Name),
|
||||
Link: create.Link,
|
||||
Description: create.Description,
|
||||
Visibility: convertVisibilityToStore(create.Visibility),
|
||||
Visibility: store.Visibility(create.Visibility.String()),
|
||||
Tag: strings.Join(create.Tags, " "),
|
||||
OpenGraphMetadata: &store.OpenGraphMetadata{
|
||||
Title: create.OpenGraphMetadata.Title,
|
||||
Description: create.OpenGraphMetadata.Description,
|
||||
Image: create.OpenGraphMetadata.Image,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
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, " ")
|
||||
shortcutUpdate.Tag = &tag
|
||||
}
|
||||
if patch.OpenGraphMetadata != nil {
|
||||
shortcutUpdate.OpenGraphMetadata = &store.OpenGraphMetadata{
|
||||
Title: patch.OpenGraphMetadata.Title,
|
||||
Description: patch.OpenGraphMetadata.Description,
|
||||
Image: patch.OpenGraphMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||
@ -176,13 +197,6 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
find.Tag = &tag
|
||||
}
|
||||
@ -268,16 +282,68 @@ func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{
|
||||
ID: shortcutID,
|
||||
}); err != nil {
|
||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||
if shortcut == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &shortcut.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to get creator")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("Creator not found")
|
||||
}
|
||||
shortcut.Creator = convertUserFromStore(user)
|
||||
|
||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||
Type: store.ActivityShortcutView,
|
||||
Level: store.ActivityInfo,
|
||||
Where: []string{fmt.Sprintf("json_extract(payload, '$.shortcutId') = %d", shortcut.ID)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to list activities")
|
||||
}
|
||||
shortcut.View = len(activityList)
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func 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 {
|
||||
payload := &ActivityShorcutCreatePayload{
|
||||
ShortcutID: shortcut.ID,
|
||||
@ -298,62 +364,3 @@ func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcu
|
||||
}
|
||||
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) {
|
||||
// GET /url/favicon?url=...
|
||||
g.GET("/url/favicon", func(c echo.Context) error {
|
||||
url := c.QueryParam("url")
|
||||
icons, err := favicon.Find(url)
|
||||
|
@ -7,7 +7,7 @@ import (
|
||||
"net/mail"
|
||||
"strconv"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@ -56,7 +56,7 @@ type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password"`
|
||||
Role Role `json:"-"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
func (create CreateUserRequest) Validate() error {
|
||||
@ -78,13 +78,56 @@ type PatchUserRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
|
||||
type UserDelete struct {
|
||||
ID int
|
||||
Role *Role `json:"role"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
g.POST("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(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 {
|
||||
ctx := c.Request().Context()
|
||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
@ -144,7 +187,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -154,13 +206,12 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
}
|
||||
|
||||
updateUser := &store.UpdateUser{
|
||||
ID: currentUserID,
|
||||
ID: userID,
|
||||
}
|
||||
if userPatch.Email != nil {
|
||||
if !validateEmail(*userPatch.Email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
||||
}
|
||||
|
||||
updateUser.Email = userPatch.Email
|
||||
}
|
||||
if userPatch.Nickname != nil {
|
||||
@ -175,6 +226,14 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
passwordHashStr := string(passwordHash)
|
||||
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)
|
||||
if err != nil {
|
||||
@ -207,6 +266,18 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user.Role == store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||
ID: userID,
|
||||
|
@ -13,11 +13,8 @@ const (
|
||||
)
|
||||
|
||||
// String returns the string format of UserSettingKey type.
|
||||
func (key UserSettingKey) String() string {
|
||||
if key == UserSettingLocaleKey {
|
||||
return "locale"
|
||||
}
|
||||
return ""
|
||||
func (k UserSettingKey) String() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
var (
|
||||
@ -27,7 +24,7 @@ var (
|
||||
type UserSetting struct {
|
||||
UserID int
|
||||
Key UserSettingKey `json:"key"`
|
||||
// Value is a JSON string with basic value
|
||||
// Value is a JSON string with basic value.
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
@ -29,6 +29,7 @@ func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
||||
s.registerAuthRoutes(apiV1Group, secret)
|
||||
s.registerUserRoutes(apiV1Group)
|
||||
s.registerShortcutRoutes(apiV1Group)
|
||||
s.registerAnalyticsRoutes(apiV1Group)
|
||||
|
||||
redirectorGroup := apiGroup.Group("/s")
|
||||
redirectorGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
|
@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
|
@ -12,21 +12,14 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/boojack/shortify/server"
|
||||
_profile "github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/shortify/store/db"
|
||||
"github.com/boojack/slash/server"
|
||||
_profile "github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/boojack/slash/store/db"
|
||||
)
|
||||
|
||||
const (
|
||||
greetingBanner = `
|
||||
███████╗██╗ ██╗ ██████╗ ██████╗ ████████╗██╗███████╗██╗ ██╗
|
||||
██╔════╝██║ ██║██╔═══██╗██╔══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
|
||||
███████╗███████║██║ ██║██████╔╝ ██║ ██║█████╗ ╚████╔╝
|
||||
╚════██║██╔══██║██║ ██║██╔══██╗ ██║ ██║██╔══╝ ╚██╔╝
|
||||
███████║██║ ██║╚██████╔╝██║ ██║ ██║ ██║██║ ██║
|
||||
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝
|
||||
`
|
||||
greetingBanner = `Welcome to Slash!`
|
||||
)
|
||||
|
||||
var (
|
||||
@ -36,8 +29,8 @@ var (
|
||||
data string
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "shortify",
|
||||
Short: "",
|
||||
Use: "slash",
|
||||
Short: `A bookmarking and url shortener, save and share your links very easily.`,
|
||||
Run: func(_cmd *cobra.Command, _args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
db := db.NewDB(profile)
|
||||
@ -89,7 +82,7 @@ func Execute() error {
|
||||
func init() {
|
||||
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().StringVarP(&data, "data", "d", "", "data directory")
|
||||
|
||||
@ -106,9 +99,9 @@ func init() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
viper.SetDefault("mode", "dev")
|
||||
viper.SetDefault("mode", "demo")
|
||||
viper.SetDefault("port", 8082)
|
||||
viper.SetEnvPrefix("shortify")
|
||||
viper.SetEnvPrefix("slash")
|
||||
}
|
||||
|
||||
func initConfig() {
|
39
docs/install.md
Normal file
39
docs/install.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Self-hosting Slash with Docker
|
||||
|
||||
Slash is designed for self-hosting through Docker. No Docker expertise is required to launch your own instance. Just basic understanding of command line and networking.
|
||||
|
||||
## Requirements
|
||||
|
||||
The only requirement is a server with Docker installed.
|
||||
|
||||
## Docker Run
|
||||
|
||||
To deploy Slash using docker run, just one command is needed:
|
||||
|
||||
```bash
|
||||
docker run -d --name slash --publish 5231:5231 --volume ~/.slash/:/var/opt/slash stevenlgtm/slash:latest
|
||||
```
|
||||
|
||||
This will start Slash in the background and expose it on port `5231`. Data is stored in `~/.slash/`. You can customize the port and data directory.
|
||||
|
||||
## Upgrade
|
||||
|
||||
To upgrade Slash to latest version, stop and remove the old container first:
|
||||
|
||||
```bash
|
||||
docker stop slash && docker rm slash
|
||||
```
|
||||
|
||||
It's recommended but optional to backup database:
|
||||
|
||||
```bash
|
||||
cp -r ~/.slash/slash_prod.db ~/.slash/slash_prod.db.bak
|
||||
```
|
||||
|
||||
Then pull the latest image:
|
||||
|
||||
```bash
|
||||
docker pull stevenlgtm/slash:latest
|
||||
```
|
||||
|
||||
Finally, restart Slash by following the steps in [Docker Run](#docker-run).
|
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
|
||||
|
||||
@ -66,8 +66,10 @@ require (
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/mssola/useragent v1.0.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
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
|
||||
)
|
||||
|
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/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
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/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
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-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-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-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
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.4.0/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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||
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-20180724234803-3673e40ba225/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"
|
||||
|
||||
[build]
|
||||
bin = "./.air/shortify"
|
||||
cmd = "go build -o ./.air/shortify ./cmd/shortify/main.go"
|
||||
bin = "./.air/slash --mode dev"
|
||||
cmd = "go build -o ./.air/slash ./cmd/slash/main.go"
|
||||
delay = 1000
|
||||
exclude_dir = [".air", "web", "build"]
|
||||
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"
|
||||
"net/http"
|
||||
|
||||
"github.com/boojack/shortify/internal/util"
|
||||
"github.com/boojack/slash/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
@ -22,16 +22,16 @@ func getFileSystem(path string) http.FileSystem {
|
||||
return http.FS(fs)
|
||||
}
|
||||
|
||||
func defaultAPIRequestSkipper(c echo.Context) bool {
|
||||
func defaultRequestSkipper(c echo.Context) bool {
|
||||
path := c.Path()
|
||||
return util.HasPrefixes(path, "/api")
|
||||
return util.HasPrefixes(path, "/api/", "/s/*")
|
||||
}
|
||||
|
||||
func embedFrontend(e *echo.Echo) {
|
||||
// Use echo static middleware to serve the built dist folder
|
||||
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: defaultAPIRequestSkipper,
|
||||
Skipper: defaultRequestSkipper,
|
||||
HTML5: true,
|
||||
Filesystem: getFileSystem("dist"),
|
||||
}))
|
||||
@ -44,7 +44,7 @@ func embedFrontend(e *echo.Echo) {
|
||||
}
|
||||
})
|
||||
assetsGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: defaultAPIRequestSkipper,
|
||||
Skipper: defaultRequestSkipper,
|
||||
HTML5: true,
|
||||
Filesystem: getFileSystem("dist/assets"),
|
||||
}))
|
||||
|
@ -4,38 +4,44 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/boojack/shortify/server/version"
|
||||
"github.com/boojack/slash/server/version"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Profile is the configuration to start main server.
|
||||
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 string `json:"mode"`
|
||||
// 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 string `json:"version"`
|
||||
}
|
||||
|
||||
func (p *Profile) IsDev() bool {
|
||||
return p.Mode != "prod"
|
||||
}
|
||||
|
||||
func checkDSN(dataDir string) (string, error) {
|
||||
// Convert to absolute path if relative path is supplied.
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
dataDir = absDir
|
||||
}
|
||||
|
||||
// Trim trailing / in case user supplies
|
||||
dataDir = strings.TrimRight(dataDir, "/")
|
||||
// Trim trailing \ or / in case user supplies
|
||||
dataDir = strings.TrimRight(dataDir, "\\/")
|
||||
|
||||
if _, err := os.Stat(dataDir); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// GetDevProfile will return a profile for dev or prod.
|
||||
// GetProfile will return a profile for dev or prod.
|
||||
func GetProfile() (*Profile, error) {
|
||||
profile := Profile{}
|
||||
err := viper.Unmarshal(&profile)
|
||||
@ -52,12 +58,23 @@ func GetProfile() (*Profile, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if profile.Mode != "dev" && profile.Mode != "prod" {
|
||||
profile.Mode = "dev"
|
||||
if profile.Mode != "demo" && profile.Mode != "dev" && profile.Mode != "prod" {
|
||||
profile.Mode = "demo"
|
||||
}
|
||||
|
||||
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)
|
||||
@ -67,7 +84,9 @@ func GetProfile() (*Profile, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
@ -5,9 +5,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
apiv1 "github.com/boojack/shortify/api/v1"
|
||||
"github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/shortify/store"
|
||||
apiv1 "github.com/boojack/slash/api/v1"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@ -52,7 +52,7 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
||||
embedFrontend(e)
|
||||
|
||||
// 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" {
|
||||
var err error
|
||||
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.
|
||||
apiV1Service := apiv1.NewAPIV1Service(profile, store)
|
||||
apiV1Service.Start(apiGroup, secret)
|
||||
apiV1Service.Start(rootGroup, secret)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
@ -9,13 +9,13 @@ import (
|
||||
|
||||
// Version is the service current released version.
|
||||
// Semantic versioning: https://semver.org/
|
||||
var Version = "0.1.0"
|
||||
var Version = "0.3.1"
|
||||
|
||||
// DevVersion is the service current development version.
|
||||
var DevVersion = "0.1.0"
|
||||
var DevVersion = "0.3.1"
|
||||
|
||||
func GetCurrentVersion(mode string) string {
|
||||
if mode == "dev" {
|
||||
if mode == "dev" || mode == "demo" {
|
||||
return DevVersion
|
||||
}
|
||||
return Version
|
||||
|
@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -64,13 +63,7 @@ type FindActivity struct {
|
||||
}
|
||||
|
||||
func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO activity (
|
||||
creator_id,
|
||||
type,
|
||||
@ -80,7 +73,7 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, created_ts
|
||||
`
|
||||
if err := tx.QueryRowContext(ctx, query,
|
||||
if err := s.db.QueryRowContext(ctx, stmt,
|
||||
create.CreatorID,
|
||||
create.Type.String(),
|
||||
create.Level.String(),
|
||||
@ -92,50 +85,11 @@ func (s *Store) CreateActivity(ctx context.Context, create *Activity) (*Activity
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity := create
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
if find.Type != "" {
|
||||
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
|
||||
FROM activity
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
list := []*Activity{}
|
||||
@ -187,3 +140,17 @@ func listActivities(ctx context.Context, tx *sql.Tx, find *FindActivity) ([]*Act
|
||||
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/shortify/server/version"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/server/version"
|
||||
)
|
||||
|
||||
//go:embed migration
|
||||
var migrationFS embed.FS
|
||||
|
||||
//go:embed seed
|
||||
var seedFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
profile *profile.Profile
|
||||
// sqlite db connection instance
|
||||
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 {
|
||||
db := &DB{
|
||||
profile: profile,
|
||||
@ -39,8 +42,21 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
return fmt.Errorf("dsn required")
|
||||
}
|
||||
|
||||
// Connect to the database without foreign_key.
|
||||
sqliteDB, err := sql.Open("sqlite", db.profile.DSN+"?cache=shared&_foreign_keys=0&_journal_mode=WAL")
|
||||
// Connect to the database with some sane settings:
|
||||
// - 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 {
|
||||
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" {
|
||||
_, err := os.Stat(db.profile.DSN)
|
||||
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 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 {
|
||||
return fmt.Errorf("failed to check database file: %w", err)
|
||||
return fmt.Errorf("failed to get db file stat, err: %w", err)
|
||||
}
|
||||
} 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)
|
||||
migrationHistoryList, err := db.FindMigrationHistoryList(ctx, &MigrationHistoryFind{})
|
||||
if err != nil {
|
||||
@ -89,7 +105,7 @@ func (db *DB) Open(ctx context.Context) (err error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// upsert the newest version to migration_history
|
||||
// Upsert the newest version to migration_history.
|
||||
version := minorVersion + ".0"
|
||||
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
|
||||
if _, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
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.
|
||||
func (db *DB) execute(ctx context.Context, stmt string) error {
|
||||
tx, err := db.DBInstance.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
if _, err := db.DBInstance.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("failed to execute statement, err: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_email ON user(email);
|
||||
|
||||
-- user_setting
|
||||
CREATE TABLE user_setting (
|
||||
user_id INTEGER NOT NULL,
|
||||
@ -41,9 +43,12 @@ CREATE TABLE shortcut (
|
||||
link TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
CREATE TABLE activity (
|
||||
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'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_email ON user(email);
|
||||
|
||||
-- user_setting
|
||||
CREATE TABLE user_setting (
|
||||
user_id INTEGER NOT NULL,
|
||||
@ -41,9 +43,12 @@ CREATE TABLE shortcut (
|
||||
link TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
@ -2,7 +2,6 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -20,47 +19,13 @@ type MigrationHistoryFind struct {
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
if v := find.Version; v != nil {
|
||||
where, args = append(where, "version = ?"), append(args, *v)
|
||||
}
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
SELECT
|
||||
version,
|
||||
created_ts
|
||||
@ -69,7 +34,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY created_ts DESC
|
||||
`
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := db.DBInstance.QueryContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -84,7 +49,6 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
|
||||
}
|
||||
|
||||
@ -95,7 +59,7 @@ func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHi
|
||||
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 := `
|
||||
INSERT INTO migration_history (
|
||||
version
|
||||
@ -107,7 +71,7 @@ func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHi
|
||||
RETURNING version, created_ts
|
||||
`
|
||||
migrationHistory := &MigrationHistory{}
|
||||
if err := tx.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||
if err := db.DBInstance.QueryRowContext(ctx, query, upsert.Version).Scan(
|
||||
&migrationHistory.Version,
|
||||
&migrationHistory.CreatedTs,
|
||||
); 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;
|
34
store/db/seed/10001__user.sql
Normal file
34
store/db/seed/10001__user.sql
Normal file
@ -0,0 +1,34 @@
|
||||
INSERT INTO
|
||||
user (
|
||||
`id`,
|
||||
`role`,
|
||||
`email`,
|
||||
`nickname`,
|
||||
`password_hash`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
101,
|
||||
'ADMIN',
|
||||
'slash@stevenlgtm.com',
|
||||
'Slasher',
|
||||
'$2a$10$H8HBWGcG/hoePhFy5SiNKOHxMD6omIpyEEWbl/fIorFC814bXW.Ua'
|
||||
);
|
||||
|
||||
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,
|
||||
'discord',
|
||||
'https://discord.gg/QZqUuUAhDV',
|
||||
'PUBLIC'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
shortcut (
|
||||
`id`,
|
||||
`creator_id`,
|
||||
`name`,
|
||||
`link`,
|
||||
`visibility`,
|
||||
`og_metadata`
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
2,
|
||||
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
|
||||
(
|
||||
3,
|
||||
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
|
||||
(
|
||||
4,
|
||||
101,
|
||||
'sqlchat',
|
||||
'https://www.sqlchat.ai',
|
||||
'WORKSPACE'
|
||||
);
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@ -31,6 +32,12 @@ func (e Visibility) String() string {
|
||||
return "PRIVATE"
|
||||
}
|
||||
|
||||
type OpenGraphMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID int
|
||||
|
||||
@ -46,6 +53,7 @@ type Shortcut struct {
|
||||
Description string
|
||||
Visibility Visibility
|
||||
Tag string
|
||||
OpenGraphMetadata *OpenGraphMetadata
|
||||
}
|
||||
|
||||
type UpdateShortcut struct {
|
||||
@ -57,6 +65,7 @@ type UpdateShortcut struct {
|
||||
Description *string
|
||||
Visibility *Visibility
|
||||
Tag *string
|
||||
OpenGraphMetadata *OpenGraphMetadata
|
||||
}
|
||||
|
||||
type FindShortcut struct {
|
||||
@ -73,24 +82,27 @@ type DeleteShortcut struct {
|
||||
}
|
||||
|
||||
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"}
|
||||
args := []any{create.CreatorID, create.Name, create.Link, create.Description, create.Visibility, create.Tag}
|
||||
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 (
|
||||
` + strings.Join(set, ", ") + `
|
||||
)
|
||||
VALUES (` + strings.Join(placeholder, ",") + `)
|
||||
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.CreatedTs,
|
||||
&create.UpdatedTs,
|
||||
@ -99,20 +111,10 @@ func (s *Store) CreateShortcut(ctx context.Context, create *Shortcut) (*Shortcut
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return create, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
if update.RowStatus != nil {
|
||||
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 {
|
||||
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 {
|
||||
return nil, fmt.Errorf("no update specified")
|
||||
}
|
||||
args = append(args, update.ID)
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
UPDATE shortcut
|
||||
SET
|
||||
` + strings.Join(set, ", ") + `
|
||||
WHERE
|
||||
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{}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
openGraphMetadataString := ""
|
||||
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.CreatorID,
|
||||
&shortcut.CreatedTs,
|
||||
@ -157,86 +167,23 @@ func (s *Store) UpdateShortcut(ctx context.Context, update *UpdateShortcut) (*Sh
|
||||
&shortcut.Description,
|
||||
&shortcut.Visibility,
|
||||
&shortcut.Tag,
|
||||
&openGraphMetadataString,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if openGraphMetadataString != "" {
|
||||
shortcut.OpenGraphMetadata = &OpenGraphMetadata{}
|
||||
if err := json.Unmarshal([]byte(openGraphMetadataString), shortcut.OpenGraphMetadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s.shortcutCache.Store(shortcut.ID, shortcut)
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
if v := find.ID; v != nil {
|
||||
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+"%")
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
creator_id,
|
||||
@ -272,7 +219,8 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
||||
link,
|
||||
description,
|
||||
visibility,
|
||||
tag
|
||||
tag,
|
||||
og_metadata
|
||||
FROM shortcut
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY created_ts DESC`,
|
||||
@ -286,6 +234,7 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
||||
list := make([]*Shortcut, 0)
|
||||
for rows.Next() {
|
||||
shortcut := &Shortcut{}
|
||||
openGraphMetadataString := ""
|
||||
if err := rows.Scan(
|
||||
&shortcut.ID,
|
||||
&shortcut.CreatorID,
|
||||
@ -297,9 +246,16 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
||||
&shortcut.Description,
|
||||
&shortcut.Visibility,
|
||||
&shortcut.Tag,
|
||||
&openGraphMetadataString,
|
||||
); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -307,5 +263,58 @@ func listShortcuts(ctx context.Context, tx *sql.Tx, find *FindShortcut) ([]*Shor
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"sync"
|
||||
|
||||
"github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
)
|
||||
|
||||
// Store provides database access to all raw objects.
|
||||
|
151
store/user.go
151
store/user.go
@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@ -55,13 +54,7 @@ type DeleteUser struct {
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO user (
|
||||
email,
|
||||
nickname,
|
||||
@ -71,7 +64,7 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, created_ts, updated_ts, row_status
|
||||
`
|
||||
if err := tx.QueryRowContext(ctx, query,
|
||||
if err := s.db.QueryRowContext(ctx, stmt,
|
||||
create.Email,
|
||||
create.Nickname,
|
||||
create.PasswordHash,
|
||||
@ -85,22 +78,12 @@ func (s *Store) CreateUser(ctx context.Context, create *User) (*User, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := create
|
||||
s.userCache.Store(user.ID, user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
if v := update.RowStatus; v != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
UPDATE user
|
||||
SET ` + strings.Join(set, ", ") + `
|
||||
WHERE id = ?
|
||||
@ -130,7 +113,7 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
||||
`
|
||||
args = append(args, update.ID)
|
||||
user := &User{}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(
|
||||
if err := s.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&user.ID,
|
||||
&user.CreatedTs,
|
||||
&user.UpdatedTs,
|
||||
@ -143,82 +126,11 @@ func (s *Store) UpdateUser(ctx context.Context, update *UpdateUser) (*User, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.userCache.Store(user.ID, user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
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 ") + `
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -279,5 +191,58 @@ func listUsers(ctx context.Context, tx *sql.Tx, find *FindUser) ([]*User, error)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO user_setting (
|
||||
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
|
||||
SET value = EXCLUDED.value
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, stmt, upsert.UserID, upsert.Key, upsert.Value); err != nil {
|
||||
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) {
|
||||
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{}
|
||||
|
||||
if v := find.Key; v != "" {
|
||||
@ -107,28 +52,71 @@ func listUserSettings(ctx context.Context, tx *sql.Tx, find *FindUserSetting) ([
|
||||
value
|
||||
FROM user_setting
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
userSettingMessageList := make([]*UserSetting, 0)
|
||||
userSettingList := make([]*UserSetting, 0)
|
||||
for rows.Next() {
|
||||
userSettingMessage := &UserSetting{}
|
||||
userSetting := &UserSetting{}
|
||||
if err := rows.Scan(
|
||||
&userSettingMessage.UserID,
|
||||
&userSettingMessage.Key,
|
||||
&userSettingMessage.Value,
|
||||
&userSetting.UserID,
|
||||
&userSetting.Key,
|
||||
&userSetting.Value,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userSettingMessageList = append(userSettingMessageList, userSettingMessage)
|
||||
userSettingList = append(userSettingList, userSetting)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
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 (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -17,13 +16,7 @@ const (
|
||||
|
||||
// String returns the string format of WorkspaceSettingKey type.
|
||||
func (key WorkspaceSettingKey) String() string {
|
||||
switch key {
|
||||
case WorkspaceDisallowSignUp:
|
||||
return "disallow-signup"
|
||||
case WorkspaceSecretSessionName:
|
||||
return "secret-session-name"
|
||||
}
|
||||
return ""
|
||||
return string(key)
|
||||
}
|
||||
|
||||
type WorkspaceSetting struct {
|
||||
@ -36,13 +29,7 @@ type FindWorkspaceSetting struct {
|
||||
}
|
||||
|
||||
func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSetting) (*WorkspaceSetting, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
stmt := `
|
||||
INSERT INTO workspace_setting (
|
||||
key,
|
||||
value
|
||||
@ -51,11 +38,7 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *WorkspaceSet
|
||||
ON CONFLICT(key) DO UPDATE
|
||||
SET value = EXCLUDED.value
|
||||
`
|
||||
if _, err := tx.ExecContext(ctx, query, upsert.Key, upsert.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, stmt, upsert.Key, upsert.Value); err != nil {
|
||||
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) {
|
||||
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{}
|
||||
|
||||
if 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
|
||||
FROM workspace_setting
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -146,5 +84,30 @@ func listWorkspaceSettings(ctx context.Context, tx *sql.Tx, find *FindWorkspaceS
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"testing"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -20,6 +20,7 @@ func TestShortcutStore(t *testing.T) {
|
||||
Description: "A test shortcut",
|
||||
Visibility: store.VisibilityPrivate,
|
||||
Tag: "test link",
|
||||
OpenGraphMetadata: &store.OpenGraphMetadata{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
shortcuts, err := ts.ListShortcuts(ctx, &store.FindShortcut{
|
||||
|
@ -5,9 +5,9 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/shortify/store/db"
|
||||
test "github.com/boojack/shortify/test"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/boojack/slash/store/db"
|
||||
test "github.com/boojack/slash/test"
|
||||
|
||||
// sqlite driver.
|
||||
_ "modernc.org/sqlite"
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@ -26,6 +26,13 @@ func TestUserStore(t *testing.T) {
|
||||
Nickname: &userPatchNickname,
|
||||
})
|
||||
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)
|
||||
err = ts.DeleteUser(ctx, &store.DeleteUser{
|
||||
ID: user.ID,
|
||||
@ -34,6 +41,9 @@ func TestUserStore(t *testing.T) {
|
||||
users, err = ts.ListUsers(ctx, &store.FindUser{})
|
||||
require.NoError(t, err)
|
||||
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.
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/boojack/shortify/store"
|
||||
"github.com/boojack/slash/store"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
@ -5,8 +5,8 @@ import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/boojack/shortify/server/profile"
|
||||
"github.com/boojack/shortify/server/version"
|
||||
"github.com/boojack/slash/server/profile"
|
||||
"github.com/boojack/slash/server/version"
|
||||
)
|
||||
|
||||
func getUnusedPort() int {
|
||||
@ -31,7 +31,7 @@ func GetTestingProfile(t *testing.T) *profile.Profile {
|
||||
Mode: mode,
|
||||
Port: port,
|
||||
Data: dir,
|
||||
DSN: fmt.Sprintf("%s/shortify_%s.db", dir, mode),
|
||||
DSN: fmt.Sprintf("%s/slash_%s.db", dir, mode),
|
||||
Version: version.GetCurrentVersion(mode),
|
||||
}
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
# Shortify
|
||||
# Slash
|
||||
|
@ -5,7 +5,7 @@
|
||||
<link rel="icon" href="/logo.png" type="image/*" />
|
||||
<meta name="theme-color" content="#FFFFFF" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<title>Shortify</title>
|
||||
<title>Slash</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "shortify",
|
||||
"name": "slash",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -12,11 +12,13 @@
|
||||
"@mui/joy": "5.0.0-alpha.84",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
"dayjs": "^1.11.3",
|
||||
"i18next": "^23.2.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.252.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
|
18
web/pnpm-lock.yaml
generated
18
web/pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ dependencies:
|
||||
axios:
|
||||
specifier: ^0.27.2
|
||||
version: 0.27.2
|
||||
classnames:
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
copy-to-clipboard:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
@ -35,6 +38,9 @@ dependencies:
|
||||
lucide-react:
|
||||
specifier: ^0.252.0
|
||||
version: 0.252.0(react@18.2.0)
|
||||
qrcode.react:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(react@18.2.0)
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
@ -1260,6 +1266,10 @@ packages:
|
||||
fsevents: 2.3.2
|
||||
dev: false
|
||||
|
||||
/classnames@2.3.2:
|
||||
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
|
||||
dev: false
|
||||
|
||||
/clsx@1.2.1:
|
||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||
engines: {node: '>=6'}
|
||||
@ -2513,6 +2523,14 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
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:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
|
@ -1,15 +1,33 @@
|
||||
import { CssVarsProvider } from "@mui/joy/styles";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import router from "./routers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { globalService } from "./services";
|
||||
import useUserStore from "./stores/v1/user";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<CssVarsProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-center" />
|
||||
</CssVarsProvider>
|
||||
);
|
||||
const userStore = useUserStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
@ -19,12 +19,11 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div className="max-w-full w-80 sm:w-96">
|
||||
<p>
|
||||
<span className="font-medium">Shortify</span> is a bookmarking and short link service that allows you to save and share links
|
||||
easily.
|
||||
<span className="font-medium">Slash</span>: A bookmarking and url shortener, save and share your links very easily.
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<span className="mr-2">See more in:</span>
|
||||
<Link variant="plain" href="https://github.com/boojack/shortify">
|
||||
<span className="mr-2">See more in</span>
|
||||
<Link variant="plain" href="https://github.com/boojack/slash" target="_blank">
|
||||
GitHub
|
||||
</Link>
|
||||
</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 { toast } from "react-hot-toast";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { userService } from "../services";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -11,6 +11,7 @@ interface Props {
|
||||
|
||||
const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose } = props;
|
||||
const userStore = useUserStore();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
const requestState = useLoading(false);
|
||||
@ -43,9 +44,8 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
requestState.setLoading();
|
||||
try {
|
||||
const user = userService.getState().user as User;
|
||||
await userService.patchUser({
|
||||
id: user.id,
|
||||
userStore.patchUser({
|
||||
id: userStore.getCurrentUser().id,
|
||||
password: newPassword,
|
||||
});
|
||||
onClose();
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy";
|
||||
import { Button, Divider, Input, Modal, ModalDialog, Radio, RadioGroup, Textarea } from "@mui/joy";
|
||||
import classnames from "classnames";
|
||||
import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { shortcutService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { showCommonDialog } from "./Alert";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -29,26 +30,34 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
description: "",
|
||||
visibility: "PRIVATE",
|
||||
tags: [],
|
||||
openGraphMetadata: {
|
||||
title: "",
|
||||
description: "",
|
||||
image: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
const [showDescriptionAndTag, setShowDescriptionAndTag] = useState<boolean>(false);
|
||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||
const [tag, setTag] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
const isEditing = !!shortcutId;
|
||||
const isCreating = isUndefined(shortcutId);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
const shortcut = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcut) {
|
||||
setState({
|
||||
...state,
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
name: shortcutTemp.name,
|
||||
link: shortcutTemp.link,
|
||||
description: shortcutTemp.description,
|
||||
visibility: shortcutTemp.visibility,
|
||||
name: shortcut.name,
|
||||
link: shortcut.link,
|
||||
description: shortcut.description,
|
||||
visibility: shortcut.visibility,
|
||||
openGraphMetadata: shortcut.openGraphMetadata,
|
||||
}),
|
||||
});
|
||||
setTag(shortcutTemp.tags.join(" "));
|
||||
setTag(shortcut.tags.join(" "));
|
||||
}
|
||||
}
|
||||
}, [shortcutId]);
|
||||
@ -76,6 +85,14 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
@ -89,27 +106,36 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
setTag(text);
|
||||
};
|
||||
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: e.target.value,
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
image: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteShortcutButtonClick = () => {
|
||||
if (!shortcutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
showCommonDialog({
|
||||
title: "Delete Shortcut",
|
||||
content: `Are you sure to delete shortcut \`${state.shortcutCreate.name}\`? You can not undo this action.`,
|
||||
style: "danger",
|
||||
onConfirm: async () => {
|
||||
await shortcutService.deleteShortcutById(shortcutId);
|
||||
onClose();
|
||||
const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
title: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
openGraphMetadata: {
|
||||
...state.shortcutCreate.openGraphMetadata,
|
||||
description: e.target.value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@ -128,6 +154,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
description: state.shortcutCreate.description,
|
||||
visibility: state.shortcutCreate.visibility,
|
||||
tags: tag.split(" "),
|
||||
openGraphMetadata: state.shortcutCreate.openGraphMetadata,
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
@ -151,12 +178,12 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
<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">{isEditing ? "Edit Shortcut" : "Create Shortcut"}</span>
|
||||
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
@ -173,30 +200,16 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Link <span className="text-red-600">*</span>
|
||||
Destination URL <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
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}
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</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">
|
||||
<span className="mb-2">
|
||||
Visibility <span className="text-red-600">*</span>
|
||||
@ -208,16 +221,106 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
))}
|
||||
</RadioGroup>
|
||||
</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>
|
||||
<Divider className="text-gray-500">Optional</Divider>
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden my-3">
|
||||
<div
|
||||
className={classnames(
|
||||
"w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100",
|
||||
showDescriptionAndTag ? "bg-gray-100 border-b" : ""
|
||||
)}
|
||||
onClick={() => setShowDescriptionAndTag(!showDescriptionAndTag)}
|
||||
>
|
||||
<span className="text-sm">Description and tags</span>
|
||||
<button className="w-7 h-7 p-1 rounded-md">
|
||||
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showDescriptionAndTag ? "transform rotate-180" : "")} />
|
||||
</button>
|
||||
</div>
|
||||
{showDescriptionAndTag && (
|
||||
<div className="w-full px-2 py-1">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Description</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="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 className="w-full flex flex-row justify-between items-center mt-8 space-x-2">
|
||||
<div>
|
||||
{isEditing && (
|
||||
<Button color="danger" variant="plain" onClick={handleDeleteShortcutButtonClick}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<div className="w-full flex flex-col justify-start items-start border rounded-md overflow-hidden">
|
||||
<div
|
||||
className={`w-full flex flex-row justify-between items-center px-2 py-1 cursor-pointer hover:bg-gray-100 ${
|
||||
showOpenGraphMetadata ? "bg-gray-100 border-b" : ""
|
||||
}`}
|
||||
onClick={() => setShowOpenGraphMetadata(!showOpenGraphMetadata)}
|
||||
>
|
||||
<span className="text-sm flex flex-row justify-start items-center">
|
||||
Social media metadata
|
||||
<Icon.Sparkles className="ml-1 w-4 h-auto text-blue-600" />
|
||||
</span>
|
||||
<button className="w-7 h-7 p-1 rounded-md">
|
||||
<Icon.ChevronDown className={classnames("w-4 h-auto text-gray-500", showOpenGraphMetadata ? "transform rotate-180" : "")} />
|
||||
</button>
|
||||
</div>
|
||||
{showOpenGraphMetadata && (
|
||||
<div className="w-full px-2 py-1">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2 text-sm">Image URL</span>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="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>
|
||||
@ -226,7 +329,6 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
);
|
||||
|
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 { toast } from "react-hot-toast";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { userService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -12,9 +11,10 @@ interface Props {
|
||||
|
||||
const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||
const { onClose } = props;
|
||||
const user = useAppSelector((state) => state.user.user as User);
|
||||
const [email, setEmail] = useState(user.email);
|
||||
const [nickname, setNickname] = useState(user.nickname);
|
||||
const userStore = useUserStore();
|
||||
const currentUser = userStore.getCurrentUser();
|
||||
const [email, setEmail] = useState(currentUser.email);
|
||||
const [nickname, setNickname] = useState(currentUser.nickname);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
@ -39,14 +39,13 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
|
||||
|
||||
requestState.setLoading();
|
||||
try {
|
||||
const user = userService.getState().user as User;
|
||||
await userService.patchUser({
|
||||
id: user.id,
|
||||
await userStore.patchUser({
|
||||
id: currentUser.id,
|
||||
email,
|
||||
nickname,
|
||||
});
|
||||
onClose();
|
||||
toast("Password changed");
|
||||
toast("User information updated");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
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 { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAppSelector } from "../stores";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const user = useAppSelector((state) => state.user).user as User;
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||
|
||||
const handleSignOutButtonClick = async () => {
|
||||
navigate("/auth");
|
||||
await api.signout();
|
||||
window.location.href = "/auth";
|
||||
};
|
||||
|
||||
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="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">
|
||||
<img src="/logo.png" className="w-8 h-auto mr-2" alt="" />
|
||||
Shortify
|
||||
Slash
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative flex-shrink-0">
|
||||
@ -30,7 +31,7 @@ const Header: React.FC = () => {
|
||||
trigger={
|
||||
<button className="flex flex-row justify-end items-center cursor-pointer">
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
<p className="w-full text-center text-gray-400 text-sm mt-2 mb-4 italic">Total {shortcutList.length} data</p>
|
||||
|
||||
{editingShortcutId && (
|
||||
<CreateShortcutDialog
|
||||
shortcutId={editingShortcutId}
|
||||
|
@ -4,13 +4,16 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { shortcutService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useFaviconStore from "../stores/v1/favicon";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { showCommonDialog } from "./Alert";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||
import AnalyticsDialog from "./AnalyticsDialog";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
@ -20,11 +23,14 @@ interface Props {
|
||||
const ShortcutView = (props: Props) => {
|
||||
const { shortcut, handleEdit } = props;
|
||||
const { t } = useTranslation();
|
||||
const user = useAppSelector((state) => state.user.user as User);
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const viewStore = useViewStore();
|
||||
const faviconStore = useFaviconStore();
|
||||
const [favicon, setFavicon] = useState<string | undefined>(undefined);
|
||||
const havePermission = user.role === "ADMIN" || shortcut.creatorId === user.id;
|
||||
const shortifyLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
|
||||
const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
|
||||
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
|
||||
useEffect(() => {
|
||||
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
|
||||
@ -35,7 +41,7 @@ const ShortcutView = (props: Props) => {
|
||||
}, [shortcut.link]);
|
||||
|
||||
const handleCopyButtonClick = () => {
|
||||
copy(shortifyLink);
|
||||
copy(shortcutLink);
|
||||
toast.success("Shortcut link copied to clipboard.");
|
||||
};
|
||||
|
||||
@ -51,28 +57,49 @@ const ShortcutView = (props: Props) => {
|
||||
};
|
||||
|
||||
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="group flex flex-row justify-start items-center mr-1 shrink-0">
|
||||
<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">
|
||||
<div className="w-6 h-6 mr-1 flex justify-center items-center overflow-clip">
|
||||
{favicon ? (
|
||||
<img className="w-[90%] h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
|
||||
<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>
|
||||
<button className="items-center cursor-pointer hover:opacity-80" onClick={() => handleCopyButtonClick()}>
|
||||
<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}
|
||||
</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" />
|
||||
<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-24"
|
||||
actionsClassName="!w-32"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
@ -81,6 +108,12 @@ const ShortcutView = (props: Props) => {
|
||||
>
|
||||
<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={() => {
|
||||
@ -97,11 +130,15 @@ const ShortcutView = (props: Props) => {
|
||||
</div>
|
||||
{shortcut.description && <p className="mt-1 text-gray-400 text-sm">{shortcut.description}</p>}
|
||||
{shortcut.tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-row justify-start items-start gap-2">
|
||||
<div className="mt-2 ml-1 flex flex-row justify-start items-start gap-2">
|
||||
<Icon.Tag className="text-gray-400 w-4 h-auto" />
|
||||
{shortcut.tags.map((tag) => {
|
||||
return (
|
||||
<span key={tag} className="text-gray-400 text-sm font-mono leading-4">
|
||||
<span
|
||||
key={tag}
|
||||
className="text-gray-400 text-sm font-mono leading-4 cursor-pointer hover:text-gray-600"
|
||||
onClick={() => viewStore.setFilter({ tag: tag })}
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
);
|
||||
@ -112,23 +149,34 @@ const ShortcutView = (props: Props) => {
|
||||
<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}
|
||||
<span className="max-w-[4rem] sm:max-w-[6rem] truncate">{shortcut.creator.nickname}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title={t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)} variant="solid" placement="top" arrow>
|
||||
<div className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full text-gray-500 text-sm">
|
||||
<div
|
||||
className="w-auto px-2 leading-6 flex flex-row justify-start items-center border rounded-full 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 text-gray-500 text-sm">
|
||||
<Icon.Eye className="w-4 h-auto mr-1" />
|
||||
<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>
|
||||
|
||||
{showQRCodeDialog && <GenerateQRCodeDialog shortcut={shortcut} onClose={() => setShowQRCodeDialog(false)} />}
|
||||
|
||||
{showAnalyticsDialog && <AnalyticsDialog shortcutId={shortcut.id} onClose={() => setShowAnalyticsDialog(false)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -21,10 +21,15 @@ const Dropdown: React.FC<Props> = (props: Props) => {
|
||||
toggleDropdownStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [dropdownStatus]);
|
||||
|
||||
|
@ -1,26 +1,26 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { useAppSelector } from "../../stores";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import ChangePasswordDialog from "../ChangePasswordDialog";
|
||||
import EditUserinfoDialog from "../EditUserinfoDialog";
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const user = useAppSelector((state) => state.user).user as User;
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const isAdmin = currentUser.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<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>}
|
||||
</p>
|
||||
<p className="flex flex-row justify-start items-center">
|
||||
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
||||
{user.email}
|
||||
{currentUser.email}
|
||||
</p>
|
||||
<div className="flex flex-row justify-start items-center gap-2 mt-2">
|
||||
<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 (
|
||||
<>
|
||||
<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-gray-400">Workspace settings</p>
|
||||
<p className="text-base font-semibold leading-6 text-gray-900">Workspace settings</p>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<Checkbox
|
||||
className="font-medium"
|
||||
label="Disable self-service signup"
|
||||
label="Disable user signup"
|
||||
checked={disallowSignUp}
|
||||
onChange={(event) => handleDisallowSignUpChange(event.target.checked)}
|
||||
/>
|
||||
<p className="mt-2 text-gray-500">Once disabled, other users cannot signup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -35,6 +35,10 @@ export function getUserById(id: number) {
|
||||
return axios.get<User>(`/api/v1/user/${id}`);
|
||||
}
|
||||
|
||||
export function createUser(userCreate: UserCreate) {
|
||||
return axios.post<User>("/api/v1/user", userCreate);
|
||||
}
|
||||
|
||||
export function patchUser(userPatch: UserPatch) {
|
||||
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
|
||||
}
|
||||
@ -45,9 +49,6 @@ export function deleteUser(userDelete: UserDelete) {
|
||||
|
||||
export function getShortcutList(shortcutFind?: ShortcutFind) {
|
||||
const queryList = [];
|
||||
if (shortcutFind?.creatorId) {
|
||||
queryList.push(`creatorId=${shortcutFind.creatorId}`);
|
||||
}
|
||||
if (shortcutFind?.tag) {
|
||||
queryList.push(`tag=${shortcutFind.tag}`);
|
||||
}
|
||||
@ -58,6 +59,10 @@ export function createShortcut(shortcutCreate: ShortcutCreate) {
|
||||
return axios.post<Shortcut>("/api/v1/shortcut", shortcutCreate);
|
||||
}
|
||||
|
||||
export function getShortcutAnalytics(shortcutId: ShortcutId) {
|
||||
return axios.get<AnalysisData>(`/api/v1/shortcut/${shortcutId}/analytics`);
|
||||
}
|
||||
|
||||
export function patchShortcut(shortcutPatch: ShortcutPatch) {
|
||||
return axios.patch<Shortcut>(`/api/v1/shortcut/${shortcutPatch.id}`, shortcutPatch);
|
||||
}
|
||||
|
@ -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 DemoBanner from "../components/DemoBanner";
|
||||
|
||||
const Root: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
navigate("/auth", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentUser && (
|
||||
<div className="w-full h-full flex flex-col justify-start items-start">
|
||||
<DemoBanner />
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -3,15 +3,15 @@
|
||||
"visibility": {
|
||||
"private": {
|
||||
"self": "Private",
|
||||
"description": "Only you can see this"
|
||||
"description": "Only you can access"
|
||||
},
|
||||
"workspace": {
|
||||
"self": "Workspace",
|
||||
"description": "Only people in your workspace can see this"
|
||||
"description": "Workspace members can access"
|
||||
},
|
||||
"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 { Toaster } from "react-hot-toast";
|
||||
import { Provider } from "react-redux";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import store from "./stores";
|
||||
import App from "./App";
|
||||
import router from "./routers";
|
||||
import "./i18n";
|
||||
import "./css/index.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container as HTMLElement);
|
||||
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<CssVarsProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-center" />
|
||||
</CssVarsProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import ChangePasswordDialog from "../components/ChangePasswordDialog";
|
||||
import EditUserinfoDialog from "../components/EditUserinfoDialog";
|
||||
|
||||
const Account: React.FC = () => {
|
||||
const user = useAppSelector((state) => state.user).user as User;
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
|
||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<span className="mr-3 text-gray-500 font-mono">Email: </span>
|
||||
{user.email}
|
||||
{currentUser.email}
|
||||
</p>
|
||||
<div className="flex flex-row justify-start items-center gap-2">
|
||||
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { Button, Tab, TabList, Tabs } from "@mui/joy";
|
||||
import { Button, Input, Tab, TabList, Tabs } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { shortcutService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "../components/Icon";
|
||||
import ShortcutListView from "../components/ShortcutListView";
|
||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||
import FilterView from "../components/FilterView";
|
||||
import OrderSetting from "../components/OrderSetting";
|
||||
|
||||
interface State {
|
||||
showCreateShortcutDialog: boolean;
|
||||
@ -13,13 +17,15 @@ interface State {
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const loadingState = useLoading();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const viewStore = useViewStore();
|
||||
const { shortcutList } = useAppSelector((state) => state.shortcut);
|
||||
const user = useAppSelector((state) => state.user).user as User;
|
||||
const [state, setState] = useState<State>({
|
||||
showCreateShortcutDialog: false,
|
||||
});
|
||||
const [selectedFilter, setSelectFilter] = useState<"ALL" | "PRIVATE">("ALL");
|
||||
const filteredShortcutList = selectedFilter === "ALL" ? shortcutList : shortcutList.filter((shortcut) => shortcut.creatorId === user.id);
|
||||
const filter = viewStore.filter;
|
||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {
|
||||
@ -37,39 +43,56 @@ const Home: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-4">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<span className="font-mono text-gray-400 mr-2">Shortcuts</span>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="Search"
|
||||
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||
endDecorator={
|
||||
filter.search && <Icon.X className="w-4 h-auto cursor-pointer" onClick={() => viewStore.setFilter({ search: "" })} />
|
||||
}
|
||||
value={filter.search}
|
||||
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<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>
|
||||
<Tab value={"ALL"}>All</Tab>
|
||||
<Tab value={"PRIVATE"}>Mine</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" /> New
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterView />
|
||||
|
||||
{loadingState.isLoading ? (
|
||||
<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>
|
||||
) : filteredShortcutList.length === 0 ? (
|
||||
<div className="py-4 w-full flex flex-col justify-center items-center">
|
||||
<Icon.PackageOpen className="w-12 h-auto text-gray-400" />
|
||||
<p className="mt-4 mb-2">No shortcuts found.</p>
|
||||
<Button size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||
Create one
|
||||
</Button>
|
||||
) : orderedShortcutList.length === 0 ? (
|
||||
<div className="py-16 w-full flex flex-col justify-center items-center">
|
||||
<Icon.PackageOpen className="w-16 h-auto text-gray-400" />
|
||||
<p className="mt-4">No shortcuts found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ShortcutListView shortcutList={filteredShortcutList} />
|
||||
<ShortcutListView shortcutList={orderedShortcutList} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@ -1,15 +1,21 @@
|
||||
import { useAppSelector } from "../stores";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import AccountSection from "../components/setting/AccountSection";
|
||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||
import UserSection from "../components/setting/UserSection";
|
||||
|
||||
const Setting: React.FC = () => {
|
||||
const user = useAppSelector((state) => state.user).user as User;
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const isAdmin = currentUser.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
|
||||
<AccountSection />
|
||||
{isAdmin && <WorkspaceSection />}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<UserSection />
|
||||
<WorkspaceSection />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,16 +1,20 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
import * as api from "../helpers/api";
|
||||
import { userService } from "../services";
|
||||
import { useAppSelector } from "../stores";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const SignIn: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const userStore = useUserStore();
|
||||
const {
|
||||
workspaceProfile: { disallowSignUp },
|
||||
workspaceProfile: {
|
||||
disallowSignUp,
|
||||
profile: { mode },
|
||||
},
|
||||
} = useAppSelector((state) => state.global);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@ -18,7 +22,16 @@ const SignIn: React.FC = () => {
|
||||
const allowConfirm = email.length > 0 && password.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
userService.doSignOut();
|
||||
if (userStore.getCurrentUser()) {
|
||||
return navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === "demo") {
|
||||
setEmail("steven@usememos.com");
|
||||
setPassword("secret");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -31,7 +44,8 @@ const SignIn: React.FC = () => {
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSigninBtnClick = async () => {
|
||||
const handleSigninBtnClick = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
@ -39,7 +53,7 @@ const SignIn: React.FC = () => {
|
||||
try {
|
||||
actionBtnLoadingState.setLoading();
|
||||
await api.signin(email, password);
|
||||
const user = await userService.doSignIn();
|
||||
const user = await userStore.fetchCurrentUser();
|
||||
if (user) {
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
@ -60,7 +74,7 @@ const SignIn: React.FC = () => {
|
||||
<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">
|
||||
<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>
|
||||
<form className="w-full" onSubmit={handleSigninBtnClick}>
|
||||
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading ? "opacity-80" : ""}`}>
|
||||
@ -70,7 +84,7 @@ const SignIn: React.FC = () => {
|
||||
className="w-full py-3"
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder="steven@shortify.com"
|
||||
placeholder="steven@slash.com"
|
||||
onChange={handleEmailInputChanged}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,13 +1,18 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-hot-toast";
|
||||
import * as api from "../helpers/api";
|
||||
import { userService } from "../services";
|
||||
import { globalService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
const SignUp: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const userStore = useUserStore();
|
||||
const {
|
||||
workspaceProfile: { disallowSignUp },
|
||||
} = globalService.getState();
|
||||
const [email, setEmail] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@ -15,7 +20,17 @@ const SignUp: React.FC = () => {
|
||||
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
userService.doSignOut();
|
||||
if (userStore.getCurrentUser()) {
|
||||
return navigate("/", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (disallowSignUp) {
|
||||
return navigate("/auth", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -33,7 +48,8 @@ const SignUp: React.FC = () => {
|
||||
setPassword(text);
|
||||
};
|
||||
|
||||
const handleSignupBtnClick = async () => {
|
||||
const handleSignupBtnClick = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (actionBtnLoadingState.isLoading) {
|
||||
return;
|
||||
}
|
||||
@ -41,7 +57,7 @@ const SignUp: React.FC = () => {
|
||||
try {
|
||||
actionBtnLoadingState.setLoading();
|
||||
await api.signup(email, nickname, password);
|
||||
const user = await userService.doSignIn();
|
||||
const user = await userStore.fetchCurrentUser();
|
||||
if (user) {
|
||||
navigate("/", {
|
||||
replace: true,
|
||||
@ -62,7 +78,7 @@ const SignUp: React.FC = () => {
|
||||
<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">
|
||||
<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>
|
||||
<p className="w-full text-center mb-4 text-2xl">Create your account</p>
|
||||
<form className="w-full" onSubmit={handleSignupBtnClick}>
|
||||
@ -73,7 +89,7 @@ const SignUp: React.FC = () => {
|
||||
className="w-full py-3"
|
||||
type="email"
|
||||
value={email}
|
||||
placeholder="steven@shortify.com"
|
||||
placeholder="steven@slash.com"
|
||||
onChange={handleEmailInputChanged}
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,83 +1,37 @@
|
||||
import { createBrowserRouter, redirect } from "react-router-dom";
|
||||
import { isNullorUndefined } from "../helpers/utils";
|
||||
import { globalService, userService } from "../services";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import Root from "../layouts/Root";
|
||||
import SignIn from "../pages/SignIn";
|
||||
import SignUp from "../pages/SignUp";
|
||||
import Home from "../pages/Home";
|
||||
import Setting from "../pages/Setting";
|
||||
import App from "../App";
|
||||
|
||||
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: "/",
|
||||
element: <App />,
|
||||
children: [
|
||||
{
|
||||
path: "auth",
|
||||
element: <SignIn />,
|
||||
},
|
||||
{
|
||||
path: "auth/signup",
|
||||
element: <SignUp />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <Root />,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <Home />,
|
||||
loader: async () => {
|
||||
try {
|
||||
await userService.initialState();
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
const { user } = userService.getState();
|
||||
if (isNullorUndefined(user)) {
|
||||
return redirect("/auth");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
element: <Setting />,
|
||||
loader: async () => {
|
||||
try {
|
||||
await userService.initialState();
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
const { user } = userService.getState();
|
||||
if (isNullorUndefined(user)) {
|
||||
return redirect("/auth");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -1,7 +1,6 @@
|
||||
import * as api from "../helpers/api";
|
||||
import store from "../stores";
|
||||
import { setGlobalState } from "../stores/modules/global";
|
||||
import userService from "./userService";
|
||||
|
||||
const globalService = {
|
||||
getState: () => {
|
||||
@ -15,12 +14,6 @@ const globalService = {
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.initialState();
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import globalService from "./globalService";
|
||||
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 { TypedUseSelectorHook, useSelector } from "react-redux";
|
||||
import globalReducer from "./modules/global";
|
||||
import userReducer from "./modules/user";
|
||||
import shortcutReducer from "./modules/shortcut";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
global: globalReducer,
|
||||
user: userReducer,
|
||||
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;
|
105
web/src/stores/v1/view.ts
Normal file
105
web/src/stores/v1/view.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface Filter {
|
||||
tag?: string;
|
||||
mineOnly?: boolean;
|
||||
visibility?: Visibility;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
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, search } = 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;
|
||||
}
|
||||
}
|
||||
if (search) {
|
||||
if (
|
||||
!shortcut.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!shortcut.description.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!shortcut.tags.some((tag) => tag.toLowerCase().includes(search.toLowerCase())) &&
|
||||
!shortcut.link.toLowerCase().includes(search.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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";
|
||||
|
||||
interface OpenGraphMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface Shortcut {
|
||||
id: ShortcutId;
|
||||
|
||||
@ -16,6 +22,7 @@ interface Shortcut {
|
||||
description: string;
|
||||
visibility: Visibility;
|
||||
tags: string[];
|
||||
openGraphMetadata: OpenGraphMetadata;
|
||||
view: number;
|
||||
}
|
||||
|
||||
@ -25,6 +32,7 @@ interface ShortcutCreate {
|
||||
description: string;
|
||||
visibility: Visibility;
|
||||
tags: string[];
|
||||
openGraphMetadata: OpenGraphMetadata;
|
||||
}
|
||||
|
||||
interface ShortcutPatch {
|
||||
@ -35,9 +43,9 @@ interface ShortcutPatch {
|
||||
description?: string;
|
||||
visibility?: Visibility;
|
||||
tags?: string[];
|
||||
openGraphMetadata?: OpenGraphMetadata;
|
||||
}
|
||||
|
||||
interface ShortcutFind {
|
||||
creatorId?: UserId;
|
||||
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;
|
||||
}
|
||||
|
||||
interface UserCreate {
|
||||
email: string;
|
||||
nickname: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
interface UserPatch {
|
||||
id: UserId;
|
||||
|
||||
@ -21,6 +28,7 @@ interface UserPatch {
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
password?: string;
|
||||
role?: Role;
|
||||
}
|
||||
|
||||
interface UserDelete {
|
||||
|
Reference in New Issue
Block a user