37 Commits

Author SHA1 Message Date
b2ce071ef0 chore: update readme 2023-07-14 21:41:34 +08:00
65545c78c6 chore: add tooltip to buttons 2023-07-14 20:45:06 +08:00
4279151238 chore: upgrade version to v0.2.0 2023-07-14 20:42:10 +08:00
3d3f55a931 chore: update air script 2023-07-14 20:42:00 +08:00
85569c032a chore: remove duplicated create button 2023-07-14 20:41:53 +08:00
bd9daddaef chore: add demo mode 2023-07-14 18:14:14 +08:00
af31875e6a chore: fix role handler 2023-07-14 13:36:32 +08:00
a0766159f2 chore: update profile 2023-07-14 13:07:01 +08:00
316617c396 chore: tweak description 2023-07-14 00:44:44 +08:00
402b766872 chore: update greeting words 2023-07-12 07:30:24 +08:00
8fade614d2 chore: fix signout 2023-07-12 07:14:23 +08:00
d8c980f56f chore: add indexes 2023-07-12 00:08:17 +08:00
b36572c5be chore: rename to slash 2023-07-11 23:51:17 +08:00
fcd72e1f98 chore: update auth checks 2023-07-11 23:39:19 +08:00
1cbab78989 feat: add chrome extension 2023-07-11 08:37:27 +08:00
28df6e35fb chore: tweak styles 2023-07-10 23:47:32 +08:00
12172f11c0 chore: update styles 2023-07-10 23:35:23 +08:00
00c7abc38d feat: add visibility filter 2023-07-10 23:19:03 +08:00
0cceed51f8 feat: implement shortcut view analytics 2023-07-10 22:57:29 +08:00
d866d5b53b chore: move auth to apiv1 2023-07-10 21:40:55 +08:00
05bc21b660 chore: update view store 2023-07-10 21:34:13 +08:00
9455824a2d feat: add tag filter 2023-07-09 11:36:26 +08:00
0b659ba124 chore: add vacuum functions 2023-07-09 01:37:20 +08:00
d82d3701dd chore: update dialog titles 2023-07-09 01:06:21 +08:00
5db3506cba feat: add member list in setting 2023-07-09 00:45:26 +08:00
c00f7d0852 chore: update request skipper 2023-07-05 00:35:24 +08:00
d900ca060a chore: remove unused scripts 2023-07-05 00:23:07 +08:00
b179f7b441 chore: update demo.gif 2023-07-04 23:08:55 +08:00
506e740438 chore: update demo gif 2023-07-04 23:02:48 +08:00
731ad57fd2 chore: update shortcut view style 2023-07-04 22:52:18 +08:00
9fd7d6bd34 chore: add demo video 2023-07-04 22:52:09 +08:00
7ca5c92769 feat: add generate qrcode dialog 2023-07-04 22:30:41 +08:00
96d44bd651 chore: update header style 2023-07-04 21:23:11 +08:00
ee9e092129 chore: code clean 2023-07-04 21:22:47 +08:00
f0334d5755 chore: update list shortcut api 2023-07-04 21:22:40 +08:00
1084381bbf chore: update jwt middleware 2023-07-04 21:07:12 +08:00
7d90b47875 chore: update shortcut view 2023-07-04 20:53:07 +08:00
90 changed files with 1740 additions and 553 deletions

View File

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

View File

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

View File

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

@ -11,5 +11,3 @@ web/dist
build build
.DS_Store .DS_Store
extension

View File

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

View File

@ -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
![demo](./resources/demo.gif)

128
api/v1/analytics.go Normal file
View 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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 != "" {

View File

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

View File

@ -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: &currentUserID,
})
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,

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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 = []

View 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!"

View File

@ -1,9 +0,0 @@
#!/bin/bash
# Usage: ./scripts/start.sh
set -e
cd "$(dirname "$0")/../"
air -c ./scripts/.air.toml

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,9 @@
DELETE FROM activity;
DELETE FROM shortcut;
DELETE FROM user_setting;
DELETE FROM user;
DELETE FROM workspace_setting;

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
# Shortify # Slash

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -4,13 +4,16 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { shortcutService } from "../services"; import { shortcutService } from "../services";
import { useAppSelector } from "../stores";
import useFaviconStore from "../stores/v1/favicon"; import useFaviconStore from "../stores/v1/favicon";
import useViewStore from "../stores/v1/view";
import useUserStore from "../stores/v1/user";
import { absolutifyLink } from "../helpers/utils"; import { absolutifyLink } from "../helpers/utils";
import { showCommonDialog } from "./Alert"; import { showCommonDialog } from "./Alert";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
import VisibilityIcon from "./VisibilityIcon"; import VisibilityIcon from "./VisibilityIcon";
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
import AnalyticsDialog from "./AnalyticsDialog";
interface Props { interface Props {
shortcut: Shortcut; shortcut: Shortcut;
@ -20,11 +23,14 @@ interface Props {
const ShortcutView = (props: Props) => { const ShortcutView = (props: Props) => {
const { shortcut, handleEdit } = props; const { shortcut, handleEdit } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const user = useAppSelector((state) => state.user.user as User); const currentUser = useUserStore().getCurrentUser();
const viewStore = useViewStore();
const faviconStore = useFaviconStore(); const faviconStore = useFaviconStore();
const [favicon, setFavicon] = useState<string | undefined>(undefined); const [favicon, setFavicon] = useState<string | undefined>(undefined);
const havePermission = user.role === "ADMIN" || shortcut.creatorId === user.id; const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false);
const shortifyLink = absolutifyLink(`/s/${shortcut.name}`); const [showAnalyticsDialog, setShowAnalyticsDialog] = useState<boolean>(false);
const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id;
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
useEffect(() => { useEffect(() => {
faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => { faviconStore.getOrFetchUrlFavicon(shortcut.link).then((url) => {
@ -35,14 +41,14 @@ const ShortcutView = (props: Props) => {
}, [shortcut.link]); }, [shortcut.link]);
const handleCopyButtonClick = () => { const handleCopyButtonClick = () => {
copy(shortifyLink); copy(shortcutLink);
toast.success("Shortcut link copied to clipboard."); toast.success("Shortcut link copied to clipboard.");
}; };
const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => { const handleDeleteShortcutButtonClick = (shortcut: Shortcut) => {
showCommonDialog({ showCommonDialog({
title: "Delete Shortcut", title: "Delete Shortcut",
content: `Are you sure to delete shortcut \`${shortcut.name}\`? You can not undo this action.`, content: `Are you sure to delete shortcut \`${shortcut.name}\`? You cannot undo this action.`,
style: "danger", style: "danger",
onConfirm: async () => { onConfirm: async () => {
await shortcutService.deleteShortcutById(shortcut.id); await shortcutService.deleteShortcutById(shortcut.id);
@ -51,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)} />}
</>
); );
}; };

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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
View 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
View 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[];
}

View File

@ -38,6 +38,5 @@ interface ShortcutPatch {
} }
interface ShortcutFind { interface ShortcutFind {
creatorId?: UserId;
tag?: string; tag?: string;
} }

View File

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