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

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