mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-06 21:22:36 +00:00
Compare commits
79 Commits
Author | SHA1 | Date | |
---|---|---|---|
101aa6a10f | |||
399364af01 | |||
660f7fd955 | |||
43be41b8a5 | |||
92eaa3c613 | |||
17fd86726d | |||
393574d57d | |||
10c94b0128 | |||
8aebafd531 | |||
70602b8c6c | |||
832b3945d9 | |||
7eeef74f4c | |||
c708585a0b | |||
6393531ac5 | |||
549dad7261 | |||
b883905f8a | |||
91e0fae1d9 | |||
9a2618f42b | |||
6bb99ed3cc | |||
6e11c28d3e | |||
f6f564913a | |||
40deb997ea | |||
59118109bd | |||
971eb4e8f7 | |||
1c70d9484e | |||
6a8c07f93a | |||
0ef6a6038a | |||
dd521103c9 | |||
b04ea04062 | |||
1774e525b3 | |||
d502e3ce74 | |||
80e52829fa | |||
c61aa8020a | |||
d2d63836d4 | |||
e0ad25b2c6 | |||
cc669f1be0 | |||
5bf86601e6 | |||
b7484363dc | |||
5264dc9d8a | |||
8649e562dc | |||
905b962e0b | |||
07c863b251 | |||
69f2c7ad89 | |||
e2c7b8c7b9 | |||
c6821a7090 | |||
b1125f3727 | |||
c7af8d6afa | |||
1025d8a2ed | |||
3f3d7a4c58 | |||
80f0af8723 | |||
730cff1148 | |||
0e3481b593 | |||
abacc9af8b | |||
d837cbd0ff | |||
3f7abce427 | |||
b6bcc3cda6 | |||
07d9436e1e | |||
5c1c238453 | |||
02fb415260 | |||
d866268a7a | |||
98d73e81c0 | |||
47821879fa | |||
7c16b1e00f | |||
29043f63b6 | |||
87d626cd1d | |||
201cf83afe | |||
35de611fd1 | |||
5c02bb98bf | |||
c1f915ae31 | |||
faae146a86 | |||
4a6c6b4b2a | |||
fafacc92eb | |||
b5f5ae2483 | |||
cdfb015638 | |||
435fe04ab3 | |||
4e73882bf1 | |||
f2d9b29baa | |||
eaf9113c92 | |||
194571e132 |
26
.github/dependabot.yml
vendored
Normal file
26
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: npm
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/frontend/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: npm
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/frontend/extension"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
14
.github/workflows/backend-tests.yml
vendored
14
.github/workflows/backend-tests.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
||||
go-static-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21
|
||||
go-version: 1.22
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Verify go.mod is tidy
|
||||
@ -23,7 +23,7 @@ jobs:
|
||||
go mod tidy
|
||||
git diff --exit-code
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with:
|
||||
version: v1.54.1
|
||||
args: --verbose --timeout=3m
|
||||
@ -32,10 +32,10 @@ jobs:
|
||||
go-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.21
|
||||
go-version: 1.22
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run all tests
|
||||
|
@ -10,10 +10,10 @@ jobs:
|
||||
build-and-push-release-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Extract build args
|
||||
# Extract version from branch name
|
||||
@ -22,20 +22,20 @@ jobs:
|
||||
echo "VERSION=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: yourselfhosted
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
|
10
.github/workflows/build-and-push-test-image.yml
vendored
10
.github/workflows/build-and-push-test-image.yml
vendored
@ -8,27 +8,27 @@ jobs:
|
||||
build-and-push-test-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: yourselfhosted
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
version: v0.9.1
|
||||
|
||||
- name: Build and Push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
|
33
.github/workflows/build-artifacts.yml
vendored
Normal file
33
.github/workflows/build-artifacts.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: Build artifacts
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22
|
||||
check-latest: true
|
||||
cache: true
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
# either 'goreleaser' (default) or 'goreleaser-pro'
|
||||
distribution: goreleaser
|
||||
# 'latest', 'nightly', or a semver
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
20
.github/workflows/extension-test.yml
vendored
20
.github/workflows/extension-test.yml
vendored
@ -14,19 +14,17 @@ jobs:
|
||||
eslint-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/extension
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/extension
|
||||
- name: Run eslint check
|
||||
run: pnpm lint
|
||||
working-directory: frontend/extension
|
||||
@ -34,19 +32,17 @@ jobs:
|
||||
extension-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/extension/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/extension
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/extension
|
||||
- name: Run extension build
|
||||
run: pnpm build
|
||||
working-directory: frontend/extension
|
||||
|
20
.github/workflows/frontend-test.yml
vendored
20
.github/workflows/frontend-test.yml
vendored
@ -14,19 +14,17 @@ jobs:
|
||||
eslint-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/web
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/web
|
||||
- name: Run eslint check
|
||||
run: pnpm lint
|
||||
working-directory: frontend/web
|
||||
@ -34,19 +32,17 @@ jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: pnpm
|
||||
cache-dependency-path: "frontend/web/pnpm-lock.yaml"
|
||||
- run: pnpm install
|
||||
working-directory: frontend/web
|
||||
- run: pnpm type-gen
|
||||
working-directory: frontend/web
|
||||
- name: Run frontend build
|
||||
run: pnpm build
|
||||
working-directory: frontend/web
|
||||
|
2
.github/workflows/proto-linter.yml
vendored
2
.github/workflows/proto-linter.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup buf
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,3 +12,5 @@ build
|
||||
node_modules
|
||||
|
||||
.env
|
||||
|
||||
dist/
|
||||
|
38
.goreleaser.yaml
Normal file
38
.goreleaser.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
version: 1
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
main: ./bin/slash
|
||||
binary: slash
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
|
||||
release:
|
||||
draft: true
|
||||
replace_existing_draft: true
|
||||
make_latest: true
|
||||
mode: replace
|
||||
skip_upload: false
|
@ -6,15 +6,16 @@ COPY . .
|
||||
|
||||
WORKDIR /frontend-build/frontend/web
|
||||
|
||||
RUN corepack enable && pnpm i --frozen-lockfile && pnpm type-gen
|
||||
RUN corepack enable && pnpm i --frozen-lockfile
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# Build backend exec file.
|
||||
FROM golang:1.21-alpine AS backend
|
||||
FROM golang:1.22-alpine AS backend
|
||||
WORKDIR /backend-build
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /frontend-build/frontend/web/dist /backend-build/server/route/frontend/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -o slash ./bin/slash/main.go
|
||||
|
||||
@ -25,7 +26,6 @@ WORKDIR /usr/local/slash
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ="UTC"
|
||||
|
||||
COPY --from=frontend /frontend-build/frontend/web/dist /usr/local/slash/dist
|
||||
COPY --from=backend /backend-build/slash /usr/local/slash/
|
||||
|
||||
EXPOSE 5231
|
||||
|
@ -1,14 +1,12 @@
|
||||
# Slash
|
||||
|
||||
<img align="right" src="./docs/assets/logo.png" height="64px" alt="logo">
|
||||
|
||||
**Slash** is an open source, self-hosted bookmarks and link sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
||||
**Slash** is an open source, self-hosted links shortener and sharing platform. It allows you to organize your links with tags, and share them with custom shortened URLs. Slash also supports team sharing of link libraries for easy collaboration.
|
||||
|
||||
🧩 Browser extension(v1.0.0) now available! - [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg), [Firefox Add-on](https://addons.mozilla.org/firefox/addon/your-slash/)
|
||||
|
||||
Getting started with Slash's [Shortcuts](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/shortcuts.md) and [Collections](https://github.com/yourselfhosted/slash/blob/main/docs/getting-started/collections.md).
|
||||
|
||||
<a href="https://demo.slash.yourselfhosted.com">Live Demo</a> • <a href="https://discord.gg/QZqUuUAhDV">Join our Discord</a>
|
||||
[👉 Join our Discord 💬](https://discord.gg/QZqUuUAhDV)
|
||||
|
||||
<p>
|
||||
<a href="https://hub.docker.com/r/yourselfhosted/slash"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/yourselfhosted/slash.svg"/></a>
|
||||
|
@ -1 +0,0 @@
|
||||
> The v1 API has been deprecated. Please use the v2 API instead.
|
@ -1,12 +0,0 @@
|
||||
package v1
|
||||
|
||||
type ActivityShorcutCreatePayload struct {
|
||||
ShortcutID int32 `json:"shortcutId"`
|
||||
}
|
||||
|
||||
type ActivityShorcutViewPayload struct {
|
||||
ShortcutID int32 `json:"shortcutId"`
|
||||
IP string `json:"ip"`
|
||||
Referer string `json:"referer"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
@ -1,131 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mssola/useragent"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/yourselfhosted/slash/internal/util"
|
||||
"github.com/yourselfhosted/slash/server/metric"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
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 := util.ConvertStringToInt32(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,
|
||||
PayloadShortcutID: &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]++
|
||||
}
|
||||
|
||||
metric.Enqueue("shortcut analytics")
|
||||
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) int {
|
||||
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) int {
|
||||
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) int {
|
||||
return i.Count - j.Count
|
||||
})
|
||||
return browserInfoSlice
|
||||
}
|
211
api/v1/auth.go
211
api/v1/auth.go
@ -1,211 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yourselfhosted/slash/api/auth"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/server/metric"
|
||||
"github.com/yourselfhosted/slash/server/service/license"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
type SignInRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SignUpRequest struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) {
|
||||
g.POST("/auth/signin", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &SignInRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signin request, err: %s", err))
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
Email: &signin.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find user by email %s", signin.Email)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("user not found with email %s", signin.Email))
|
||||
} else if user.RowStatus == store.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("user has been archived with email %s", signin.Email))
|
||||
}
|
||||
|
||||
// Compare the stored hashed password, with the hashed version of the password that was received.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(signin.Password)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "unmatched email and password")
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
metric.Enqueue("user sign in")
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/signup", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get workspace setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if enableSignUpSetting != nil && !enableSignUpSetting.GetEnableSignup() {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "sign up has been disabled")
|
||||
}
|
||||
|
||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||
}
|
||||
if len(userList) >= 5 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||
}
|
||||
}
|
||||
|
||||
signup := &SignUpRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted signup request, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
|
||||
create := &store.User{
|
||||
Email: signup.Email,
|
||||
Nickname: signup.Nickname,
|
||||
PasswordHash: string(passwordHash),
|
||||
}
|
||||
existingUsers, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find existing users, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
// The first user to sign up is an admin by default.
|
||||
if len(existingUsers) == 0 {
|
||||
create.Role = store.RoleAdmin
|
||||
} else {
|
||||
create.Role = store.RoleUser
|
||||
}
|
||||
|
||||
user, err := s.Store.CreateUser(ctx, create)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
accessToken, err := auth.GenerateAccessToken(user.Email, user.ID, time.Now().Add(auth.AccessTokenDuration), []byte(secret))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate tokens, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if err := s.UpsertAccessTokenToStore(ctx, user, accessToken); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert access token, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
cookieExp := time.Now().Add(auth.CookieExpDuration)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, accessToken, cookieExp)
|
||||
metric.Enqueue("user sign up")
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/logout", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
RemoveTokensAndCookies(c)
|
||||
accessToken := findAccessToken(c)
|
||||
userID, _ := getUserIDFromAccessToken(accessToken, secret)
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
// Auto remove the current access token from the user access tokens.
|
||||
if err == nil && len(userAccessTokens) != 0 {
|
||||
accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{}
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessToken != userAccessToken.AccessToken {
|
||||
accessTokens = append(accessTokens, userAccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: userID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: accessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
}
|
||||
c.Response().WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpsertAccessTokenToStore(ctx context.Context, user *store.User, accessToken string) error {
|
||||
userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get user access tokens")
|
||||
}
|
||||
userAccessToken := storepb.AccessTokensUserSetting_AccessToken{
|
||||
AccessToken: accessToken,
|
||||
Description: "Account sign in",
|
||||
}
|
||||
userAccessTokens = append(userAccessTokens, &userAccessToken)
|
||||
if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{
|
||||
UserId: user.ID,
|
||||
Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS,
|
||||
Value: &storepb.UserSetting_AccessTokens{
|
||||
AccessTokens: &storepb.AccessTokensUserSetting{
|
||||
AccessTokens: userAccessTokens,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTokensAndCookies removes the jwt token from the cookies.
|
||||
func RemoveTokensAndCookies(c echo.Context) {
|
||||
cookieExp := time.Now().Add(-1 * time.Hour)
|
||||
setTokenCookie(c, auth.AccessTokenCookieName, "", cookieExp)
|
||||
}
|
||||
|
||||
// setTokenCookie sets the token to the cookie.
|
||||
func setTokenCookie(c echo.Context, name, token string, expiration time.Time) {
|
||||
cookie := new(http.Cookie)
|
||||
cookie.Name = name
|
||||
cookie.Value = token
|
||||
cookie.Expires = expiration
|
||||
cookie.Path = "/"
|
||||
// Http-only helps mitigate the risk of client side script accessing the protected cookie.
|
||||
cookie.HttpOnly = true
|
||||
cookie.SameSite = http.SameSiteStrictMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
package v1
|
||||
|
||||
// RowStatus is the status for a row.
|
||||
type RowStatus string
|
||||
|
||||
const (
|
||||
// Normal is the status for a normal row.
|
||||
Normal RowStatus = "NORMAL"
|
||||
// Archived is the status for an archived row.
|
||||
Archived RowStatus = "ARCHIVED"
|
||||
)
|
||||
|
||||
func (s RowStatus) String() string {
|
||||
return string(s)
|
||||
}
|
133
api/v1/jwt.go
133
api/v1/jwt.go
@ -1,133 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/yourselfhosted/slash/api/auth"
|
||||
"github.com/yourselfhosted/slash/internal/util"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// The key name used to store user id in the context
|
||||
// user id is extracted from the jwt token subject field.
|
||||
userIDContextKey = "user-id"
|
||||
)
|
||||
|
||||
func extractTokenFromHeader(c echo.Context) (string, error) {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
authHeaderParts := strings.Fields(authHeader)
|
||||
if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" {
|
||||
return "", errors.New("Authorization header format must be Bearer {token}")
|
||||
}
|
||||
|
||||
return authHeaderParts[1], nil
|
||||
}
|
||||
|
||||
func findAccessToken(c echo.Context) string {
|
||||
// Check the HTTP request header first.
|
||||
accessToken, _ := extractTokenFromHeader(c)
|
||||
if accessToken == "" {
|
||||
// Check the cookie.
|
||||
cookie, _ := c.Cookie(auth.AccessTokenCookieName)
|
||||
if cookie != nil {
|
||||
accessToken = cookie.Value
|
||||
}
|
||||
}
|
||||
return accessToken
|
||||
}
|
||||
|
||||
// JWTMiddleware validates the access token.
|
||||
func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
path := c.Request().URL.Path
|
||||
method := c.Request().Method
|
||||
|
||||
// Pass auth and profile endpoints.
|
||||
if util.HasPrefixes(path, "/api/v1/auth", "/api/v1/workspace/profile") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
accessToken := findAccessToken(c)
|
||||
if accessToken == "" {
|
||||
// When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts.
|
||||
if util.HasPrefixes(path, "/s/", "/api/v1/user/") && method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token")
|
||||
}
|
||||
|
||||
userID, err := getUserIDFromAccessToken(accessToken, secret)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token")
|
||||
}
|
||||
|
||||
accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err)
|
||||
}
|
||||
if !validateAccessToken(accessToken, accessTokens) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.")
|
||||
}
|
||||
|
||||
// Even if there is no error, we still need to make sure the user still exists.
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Server error to find user ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Failed to find user ID: %d", userID))
|
||||
}
|
||||
|
||||
// Stores userID into context.
|
||||
c.Set(userIDContextKey, userID)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func getUserIDFromAccessToken(accessToken, secret string) (int32, error) {
|
||||
claims := &auth.ClaimsMessage{}
|
||||
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
|
||||
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
|
||||
}
|
||||
if kid, ok := t.Header["kid"].(string); ok {
|
||||
if kid == "v1" {
|
||||
return []byte(secret), nil
|
||||
}
|
||||
}
|
||||
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
|
||||
})
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Invalid or expired access token")
|
||||
}
|
||||
// We either have a valid access token or we will attempt to generate new access token.
|
||||
userID, err := util.ConvertStringToInt32(claims.Subject)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Malformed ID in the token")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
|
||||
for _, userAccessToken := range userAccessTokens {
|
||||
if accessTokenString == userAccessToken.AccessToken {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
@ -1,386 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/yourselfhosted/slash/internal/util"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/server/metric"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
// Visibility is the type of a shortcut visibility.
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
// VisibilityPublic is the PUBLIC visibility.
|
||||
VisibilityPublic Visibility = "PUBLIC"
|
||||
// VisibilityWorkspace is the WORKSPACE visibility.
|
||||
VisibilityWorkspace Visibility = "WORKSPACE"
|
||||
// VisibilityPrivate is the PRIVATE visibility.
|
||||
VisibilityPrivate Visibility = "PRIVATE"
|
||||
)
|
||||
|
||||
func (v Visibility) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
type OpenGraphMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatorID int32 `json:"creatorId"`
|
||||
Creator *User `json:"creator"`
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
View int `json:"view"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type CreateShortcutRequest struct {
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Visibility Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
type PatchShortcutRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Name *string `json:"name"`
|
||||
Link *string `json:"link"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *Visibility `json:"visibility"`
|
||||
Tags []string `json:"tags"`
|
||||
OpenGraphMetadata *OpenGraphMetadata `json:"openGraphMetadata"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerShortcutRoutes(g *echo.Group) {
|
||||
g.POST("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
create := &CreateShortcutRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(create); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("malformatted post shortcut request, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut := &storepb.Shortcut{
|
||||
CreatorId: userID,
|
||||
Name: create.Name,
|
||||
Link: create.Link,
|
||||
Title: create.Title,
|
||||
Description: create.Description,
|
||||
Visibility: convertVisibilityToStorepb(create.Visibility),
|
||||
Tags: create.Tags,
|
||||
OgMetadata: &storepb.OpenGraphMetadata{},
|
||||
}
|
||||
if create.Name == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "name is required")
|
||||
}
|
||||
if create.OpenGraphMetadata != nil {
|
||||
shortcut.OgMetadata = &storepb.OpenGraphMetadata{
|
||||
Title: create.OpenGraphMetadata.Title,
|
||||
Description: create.OpenGraphMetadata.Description,
|
||||
Image: create.OpenGraphMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err := s.Store.CreateShortcut(ctx, shortcut)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
if err := s.createShortcutCreateActivity(ctx, shortcut); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create shortcut activity, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
metric.Enqueue("shortcut create")
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := util.ConvertStringToInt32(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)
|
||||
}
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, 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)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "unauthorized to update shortcut")
|
||||
}
|
||||
|
||||
patch := &PatchShortcutRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(patch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode patch shortcut request, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutUpdate := &store.UpdateShortcut{
|
||||
ID: shortcutID,
|
||||
Name: patch.Name,
|
||||
Link: patch.Link,
|
||||
Title: patch.Title,
|
||||
Description: patch.Description,
|
||||
}
|
||||
if patch.RowStatus != nil {
|
||||
shortcutUpdate.RowStatus = (*store.RowStatus)(patch.RowStatus)
|
||||
}
|
||||
if patch.Visibility != nil {
|
||||
shortcutUpdate.Visibility = (*store.Visibility)(patch.Visibility)
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
tag := strings.Join(patch.Tags, " ")
|
||||
shortcutUpdate.Tag = &tag
|
||||
}
|
||||
if patch.OpenGraphMetadata != nil {
|
||||
shortcutUpdate.OpenGraphMetadata = &storepb.OpenGraphMetadata{
|
||||
Title: patch.OpenGraphMetadata.Title,
|
||||
Description: patch.OpenGraphMetadata.Description,
|
||||
Image: patch.OpenGraphMetadata.Image,
|
||||
}
|
||||
}
|
||||
shortcut, err = s.Store.UpdateShortcut(ctx, shortcutUpdate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to patch shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.GET("/shortcut", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
|
||||
find := &store.FindShortcut{}
|
||||
if tag := c.QueryParam("tag"); tag != "" {
|
||||
find.Tag = &tag
|
||||
}
|
||||
|
||||
list := []*storepb.Shortcut{}
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityWorkspace, store.VisibilityPublic}
|
||||
visibleShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut list, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
list = append(list, visibleShortcutList...)
|
||||
|
||||
find.VisibilityList = []store.Visibility{store.VisibilityPrivate}
|
||||
find.CreatorID = &userID
|
||||
privateShortcutList, err := s.Store.ListShortcuts(ctx, find)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch private shortcut list, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
list = append(list, privateShortcutList...)
|
||||
|
||||
shortcutMessageList := []*Shortcut{}
|
||||
for _, shortcut := range list {
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
shortcutMessageList = append(shortcutMessageList, shortcutMessage)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessageList)
|
||||
})
|
||||
|
||||
g.GET("/shortcut/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
|
||||
shortcutMessage, err := s.composeShortcut(ctx, convertShortcutFromStorepb(shortcut))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to compose shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, shortcutMessage)
|
||||
})
|
||||
|
||||
g.DELETE("/shortcut/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
shortcutID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("shortcut id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, 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)
|
||||
}
|
||||
|
||||
shortcut, err := s.Store.GetShortcut(ctx, &store.FindShortcut{
|
||||
ID: &shortcutID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to fetch shortcut by id, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if shortcut == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found shortcut with id: %d", shortcutID))
|
||||
}
|
||||
if shortcut.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Unauthorized to delete shortcut")
|
||||
}
|
||||
|
||||
err = s.Store.DeleteShortcut(ctx, &store.DeleteShortcut{ID: shortcutID})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to delete shortcut, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIV1Service) composeShortcut(ctx context.Context, shortcut *Shortcut) (*Shortcut, error) {
|
||||
if shortcut == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &shortcut.CreatorID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to get creator")
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("Creator not found")
|
||||
}
|
||||
shortcut.Creator = convertUserFromStore(user)
|
||||
|
||||
activityList, err := s.Store.ListActivities(ctx, &store.FindActivity{
|
||||
Type: store.ActivityShortcutView,
|
||||
Level: store.ActivityInfo,
|
||||
PayloadShortcutID: &shortcut.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Failed to list activities")
|
||||
}
|
||||
shortcut.View = len(activityList)
|
||||
|
||||
return shortcut, nil
|
||||
}
|
||||
|
||||
func convertShortcutFromStorepb(shortcut *storepb.Shortcut) *Shortcut {
|
||||
return &Shortcut{
|
||||
ID: shortcut.Id,
|
||||
CreatedTs: shortcut.CreatedTs,
|
||||
UpdatedTs: shortcut.UpdatedTs,
|
||||
CreatorID: shortcut.CreatorId,
|
||||
RowStatus: RowStatus(shortcut.RowStatus.String()),
|
||||
Name: shortcut.Name,
|
||||
Link: shortcut.Link,
|
||||
Title: shortcut.Title,
|
||||
Description: shortcut.Description,
|
||||
Visibility: Visibility(shortcut.Visibility.String()),
|
||||
Tags: shortcut.Tags,
|
||||
OpenGraphMetadata: &OpenGraphMetadata{
|
||||
Title: shortcut.OgMetadata.Title,
|
||||
Description: shortcut.OgMetadata.Description,
|
||||
Image: shortcut.OgMetadata.Image,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertVisibilityToStorepb(visibility Visibility) storepb.Visibility {
|
||||
switch visibility {
|
||||
case VisibilityPublic:
|
||||
return storepb.Visibility_PUBLIC
|
||||
case VisibilityWorkspace:
|
||||
return storepb.Visibility_WORKSPACE
|
||||
case VisibilityPrivate:
|
||||
return storepb.Visibility_PRIVATE
|
||||
default:
|
||||
return storepb.Visibility_PUBLIC
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) createShortcutCreateActivity(ctx context.Context, shortcut *storepb.Shortcut) error {
|
||||
payload := &ActivityShorcutCreatePayload{
|
||||
ShortcutID: shortcut.Id,
|
||||
}
|
||||
payloadStr, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to marshal activity payload")
|
||||
}
|
||||
activity := &store.Activity{
|
||||
CreatorID: shortcut.CreatorId,
|
||||
Type: store.ActivityShortcutCreate,
|
||||
Level: store.ActivityInfo,
|
||||
Payload: string(payloadStr),
|
||||
}
|
||||
_, err = s.Store.CreateActivity(ctx, activity)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed to create activity")
|
||||
}
|
||||
return nil
|
||||
}
|
340
api/v1/user.go
340
api/v1/user.go
@ -1,340 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yourselfhosted/slash/internal/util"
|
||||
"github.com/yourselfhosted/slash/server/metric"
|
||||
"github.com/yourselfhosted/slash/server/service/license"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// BotID is the id of bot.
|
||||
BotID = 0
|
||||
)
|
||||
|
||||
// Role is the type of a role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleAdmin is the ADMIN role.
|
||||
RoleAdmin Role = "ADMIN"
|
||||
// RoleUser is the USER role.
|
||||
RoleUser Role = "USER"
|
||||
)
|
||||
|
||||
func (r Role) String() string {
|
||||
switch r {
|
||||
case RoleAdmin:
|
||||
return "ADMIN"
|
||||
case RoleUser:
|
||||
return "USER"
|
||||
}
|
||||
return "USER"
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int32 `json:"id"`
|
||||
|
||||
// Standard fields
|
||||
CreatedTs int64 `json:"createdTs"`
|
||||
UpdatedTs int64 `json:"updatedTs"`
|
||||
RowStatus RowStatus `json:"rowStatus"`
|
||||
|
||||
// Domain specific fields
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
func (create CreateUserRequest) Validate() error {
|
||||
if create.Email != "" && !validateEmail(create.Email) {
|
||||
return errors.New("invalid email format")
|
||||
}
|
||||
if create.Nickname != "" && len(create.Nickname) < 3 {
|
||||
return errors.New("nickname is too short, minimum length is 3")
|
||||
}
|
||||
if len(create.Password) < 3 {
|
||||
return errors.New("password is too short, minimum length is 3")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type PatchUserRequest struct {
|
||||
RowStatus *RowStatus `json:"rowStatus"`
|
||||
Email *string `json:"email"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Password *string `json:"password"`
|
||||
Role *Role `json:"role"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
|
||||
g.POST("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
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")
|
||||
}
|
||||
|
||||
if !s.LicenseService.IsFeatureEnabled(license.FeatureTypeUnlimitedAccounts) {
|
||||
userList, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to list users").SetInternal(err)
|
||||
}
|
||||
if len(userList) >= 5 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Maximum number of users reached")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
metric.Enqueue("user create")
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
g.GET("/user", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
list, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to list users, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
userList := []*User{}
|
||||
for _, user := range list {
|
||||
userList = append(userList, convertUserFromStore(user))
|
||||
}
|
||||
return c.JSON(http.StatusOK, userList)
|
||||
})
|
||||
|
||||
// GET /api/user/me is used to check if the user is logged in.
|
||||
g.GET("/user/me", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing auth session")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.GET("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
userMessage := convertUserFromStore(user)
|
||||
userID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
userMessage.Email = ""
|
||||
}
|
||||
return c.JSON(http.StatusOK, userMessage)
|
||||
})
|
||||
|
||||
g.PATCH("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: ¤tUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||
}
|
||||
|
||||
userPatch := &PatchUserRequest{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to decode request body, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
updateUser := &store.UpdateUser{
|
||||
ID: userID,
|
||||
}
|
||||
if userPatch.Email != nil {
|
||||
if !validateEmail(*userPatch.Email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid email format: %s", *userPatch.Email))
|
||||
}
|
||||
updateUser.Email = userPatch.Email
|
||||
}
|
||||
if userPatch.Nickname != nil {
|
||||
updateUser.Nickname = userPatch.Nickname
|
||||
}
|
||||
if userPatch.Password != nil && *userPatch.Password != "" {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to hash password, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
passwordHashStr := string(passwordHash)
|
||||
updateUser.PasswordHash = &passwordHashStr
|
||||
}
|
||||
if userPatch.RowStatus != nil {
|
||||
rowStatus := store.RowStatus(*userPatch.RowStatus)
|
||||
updateUser.RowStatus = &rowStatus
|
||||
}
|
||||
if userPatch.Role != nil {
|
||||
adminRole := store.RoleAdmin
|
||||
adminUsers, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||
Role: &adminRole,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to list admin users, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if len(adminUsers) == 1 && adminUsers[0].ID == userID && *userPatch.Role != RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "cannot remove admin role from the last admin user")
|
||||
}
|
||||
role := store.Role(*userPatch.Role)
|
||||
updateUser.Role = &role
|
||||
}
|
||||
|
||||
user, err := s.Store.UpdateUser(ctx, updateUser)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to update user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, convertUserFromStore(user))
|
||||
})
|
||||
|
||||
g.DELETE("/user/:id", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
currentUserID, ok := c.Get(userIDContextKey).(int32)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
|
||||
}
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: ¤tUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find current session user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("current session user not found with ID: %d", currentUserID)).SetInternal(err)
|
||||
}
|
||||
if currentUser.Role != store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err)
|
||||
}
|
||||
|
||||
userID, err := util.ConvertStringToInt32(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err)
|
||||
}
|
||||
user, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
if user.Role == store.RoleAdmin {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err)
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
||||
ID: userID,
|
||||
}); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete user, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, true)
|
||||
})
|
||||
}
|
||||
|
||||
// validateEmail validates the email.
|
||||
func validateEmail(email string) bool {
|
||||
if _, err := mail.ParseAddress(email); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// convertUserFromStore converts a store user to a user.
|
||||
func convertUserFromStore(user *store.User) *User {
|
||||
return &User{
|
||||
ID: user.ID,
|
||||
CreatedTs: user.CreatedTs,
|
||||
UpdatedTs: user.UpdatedTs,
|
||||
RowStatus: RowStatus(user.RowStatus),
|
||||
Email: user.Email,
|
||||
Nickname: user.Nickname,
|
||||
Role: Role(user.Role),
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type UserSettingKey string
|
||||
|
||||
const (
|
||||
// UserSettingLocaleKey is the key type for user locale.
|
||||
UserSettingLocaleKey UserSettingKey = "locale"
|
||||
)
|
||||
|
||||
// String returns the string format of UserSettingKey type.
|
||||
func (k UserSettingKey) String() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
var (
|
||||
UserSettingLocaleValue = []string{"en", "zh"}
|
||||
)
|
||||
|
||||
type UserSetting struct {
|
||||
UserID int
|
||||
Key UserSettingKey `json:"key"`
|
||||
// Value is a JSON string with basic value.
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type UserSettingUpsert struct {
|
||||
UserID int
|
||||
Key UserSettingKey `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (upsert UserSettingUpsert) Validate() error {
|
||||
if upsert.Key == UserSettingLocaleKey {
|
||||
localeValue := "en"
|
||||
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
|
||||
if err != nil {
|
||||
return errors.New("failed to unmarshal user setting locale value")
|
||||
}
|
||||
|
||||
invalid := true
|
||||
for _, value := range UserSettingLocaleValue {
|
||||
if localeValue == value {
|
||||
invalid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
return errors.New("invalid user setting locale value")
|
||||
}
|
||||
} else {
|
||||
return errors.New("invalid user setting key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserSettingFind struct {
|
||||
UserID int
|
||||
|
||||
Key *UserSettingKey `json:"key"`
|
||||
}
|
35
api/v1/v1.go
35
api/v1/v1.go
@ -1,35 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/yourselfhosted/slash/server/profile"
|
||||
"github.com/yourselfhosted/slash/server/service/license"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
type APIV1Service struct {
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
LicenseService *license.LicenseService
|
||||
}
|
||||
|
||||
func NewAPIV1Service(profile *profile.Profile, store *store.Store, licenseService *license.LicenseService) *APIV1Service {
|
||||
return &APIV1Service{
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
LicenseService: licenseService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIV1Service) Start(apiGroup *echo.Group, secret string) {
|
||||
apiV1Group := apiGroup.Group("/api/v1")
|
||||
apiV1Group.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return JWTMiddleware(s, next, secret)
|
||||
})
|
||||
s.registerWorkspaceRoutes(apiV1Group)
|
||||
s.registerAuthRoutes(apiV1Group, secret)
|
||||
s.registerUserRoutes(apiV1Group)
|
||||
s.registerShortcutRoutes(apiV1Group)
|
||||
s.registerAnalyticsRoutes(apiV1Group)
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/server/profile"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
type WorkspaceProfile struct {
|
||||
Profile *profile.Profile `json:"profile"`
|
||||
DisallowSignUp bool `json:"disallowSignUp"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) registerWorkspaceRoutes(g *echo.Group) {
|
||||
g.GET("/workspace/profile", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
workspaceProfile := WorkspaceProfile{
|
||||
Profile: s.Profile,
|
||||
DisallowSignUp: false,
|
||||
}
|
||||
|
||||
enableSignUpSetting, err := s.Store.GetWorkspaceSetting(ctx, &store.FindWorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_WORKSAPCE_SETTING_ENABLE_SIGNUP,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to find workspace setting, err: %s", err)).SetInternal(err)
|
||||
}
|
||||
if enableSignUpSetting != nil {
|
||||
workspaceProfile.DisallowSignUp = !enableSignUpSetting.GetEnableSignup()
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, workspaceProfile)
|
||||
})
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
package v2
|
||||
|
||||
import "strings"
|
||||
|
||||
var allowedMethodsWhenUnauthorized = map[string]bool{
|
||||
"/slash.api.v2.WorkspaceService/GetWorkspaceProfile": true,
|
||||
"/slash.api.v2.WorkspaceService/GetWorkspaceSetting": true,
|
||||
"/slash.api.v2.AuthService/SignIn": true,
|
||||
"/slash.api.v2.AuthService/SignUp": true,
|
||||
"/slash.api.v2.AuthService/SignOut": true,
|
||||
"/memos.api.v2.AuthService/GetAuthStatus": true,
|
||||
"/slash.api.v2.ShortcutService/GetShortcutByName": true,
|
||||
"/slash.api.v2.CollectionService/GetCollectionByName": true,
|
||||
}
|
||||
|
||||
// isUnauthorizeAllowedMethod returns true if the method is allowed to be called when the user is not authorized.
|
||||
func isUnauthorizeAllowedMethod(methodName string) bool {
|
||||
if strings.HasPrefix(methodName, "/grpc.reflection") {
|
||||
return true
|
||||
}
|
||||
return allowedMethodsWhenUnauthorized[methodName]
|
||||
}
|
||||
|
||||
var allowedMethodsOnlyForAdmin = map[string]bool{
|
||||
"/slash.api.v2.UserService/CreateUser": true,
|
||||
"/slash.api.v2.UserService/DeleteUser": true,
|
||||
"/slash.api.v2.WorkspaceService/UpdateWorkspaceSetting": true,
|
||||
"/slash.api.v2.SubscriptionService/UpdateSubscription": true,
|
||||
}
|
||||
|
||||
// isOnlyForAdminAllowedMethod returns true if the method is allowed to be called only by admin.
|
||||
func isOnlyForAdminAllowedMethod(methodName string) bool {
|
||||
return allowedMethodsOnlyForAdmin[methodName]
|
||||
}
|
@ -1,187 +0,0 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv2pb "github.com/yourselfhosted/slash/proto/gen/api/v2"
|
||||
storepb "github.com/yourselfhosted/slash/proto/gen/store"
|
||||
"github.com/yourselfhosted/slash/store"
|
||||
)
|
||||
|
||||
func (s *APIV2Service) ListMemos(ctx context.Context, _ *apiv2pb.ListMemosRequest) (*apiv2pb.ListMemosResponse, error) {
|
||||
find := &store.FindMemo{}
|
||||
memos, err := s.Store.ListMemos(ctx, find)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to fetch memo list, err: %v", err)
|
||||
}
|
||||
|
||||
composedMemos := []*apiv2pb.Memo{}
|
||||
for _, memo := range memos {
|
||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||
}
|
||||
composedMemos = append(composedMemos, composedMemo)
|
||||
}
|
||||
|
||||
response := &apiv2pb.ListMemosResponse{
|
||||
Memos: composedMemos,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) GetMemo(ctx context.Context, request *apiv2pb.GetMemoRequest) (*apiv2pb.GetMemoResponse, error) {
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
||||
}
|
||||
if memo == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||
}
|
||||
|
||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.GetMemoResponse{
|
||||
Memo: composedMemo,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) CreateMemo(ctx context.Context, request *apiv2pb.CreateMemoRequest) (*apiv2pb.CreateMemoResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
memo := &storepb.Memo{
|
||||
CreatorId: userID,
|
||||
Name: request.Memo.Name,
|
||||
Title: request.Memo.Title,
|
||||
Content: request.Memo.Content,
|
||||
Tags: request.Memo.Tags,
|
||||
Visibility: storepb.Visibility(request.Memo.Visibility),
|
||||
}
|
||||
memo, err := s.Store.CreateMemo(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create memo, err: %v", err)
|
||||
}
|
||||
|
||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.CreateMemoResponse{
|
||||
Memo: composedMemo,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) UpdateMemo(ctx context.Context, request *apiv2pb.UpdateMemoRequest) (*apiv2pb.UpdateMemoResponse, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "updateMask is required")
|
||||
}
|
||||
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||
}
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &request.Memo.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
||||
}
|
||||
if memo == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||
}
|
||||
if memo.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
update := &store.UpdateMemo{
|
||||
ID: memo.Id,
|
||||
}
|
||||
for _, path := range request.UpdateMask.Paths {
|
||||
switch path {
|
||||
case "name":
|
||||
update.Name = &request.Memo.Name
|
||||
case "title":
|
||||
update.Title = &request.Memo.Title
|
||||
case "content":
|
||||
update.Content = &request.Memo.Content
|
||||
case "tags":
|
||||
tag := strings.Join(request.Memo.Tags, " ")
|
||||
update.Tag = &tag
|
||||
case "visibility":
|
||||
visibility := store.Visibility(request.Memo.Visibility.String())
|
||||
update.Visibility = &visibility
|
||||
}
|
||||
}
|
||||
memo, err = s.Store.UpdateMemo(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update memo, err: %v", err)
|
||||
}
|
||||
|
||||
composedMemo, err := s.convertMemoFromStorepb(ctx, memo)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert memo, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.UpdateMemoResponse{
|
||||
Memo: composedMemo,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV2Service) DeleteMemo(ctx context.Context, request *apiv2pb.DeleteMemoRequest) (*apiv2pb.DeleteMemoResponse, error) {
|
||||
userID := ctx.Value(userIDContextKey).(int32)
|
||||
currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user, err: %v", err)
|
||||
}
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||||
ID: &request.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo by ID: %v", err)
|
||||
}
|
||||
if memo == nil {
|
||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||
}
|
||||
if memo.CreatorId != userID && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "Permission denied")
|
||||
}
|
||||
|
||||
err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{
|
||||
ID: memo.Id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete memo, err: %v", err)
|
||||
}
|
||||
response := &apiv2pb.DeleteMemoResponse{}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (*APIV2Service) convertMemoFromStorepb(_ context.Context, memo *storepb.Memo) (*apiv2pb.Memo, error) {
|
||||
return &apiv2pb.Memo{
|
||||
Id: memo.Id,
|
||||
CreatedTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
|
||||
UpdatedTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
|
||||
CreatorId: memo.CreatorId,
|
||||
Name: memo.Name,
|
||||
Title: memo.Title,
|
||||
Content: memo.Content,
|
||||
Tags: memo.Tags,
|
||||
Visibility: apiv2pb.Visibility(memo.Visibility),
|
||||
}, nil
|
||||
}
|
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@ -10,9 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/yourselfhosted/slash/internal/log"
|
||||
"github.com/yourselfhosted/slash/server"
|
||||
"github.com/yourselfhosted/slash/server/metric"
|
||||
"github.com/yourselfhosted/slash/server/profile"
|
||||
@ -35,18 +34,18 @@ var (
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "slash",
|
||||
Short: `An open source, self-hosted bookmarks and link sharing platform.`,
|
||||
Run: func(_cmd *cobra.Command, _args []string) {
|
||||
Short: `An open source, self-hosted links shortener and sharing platform.`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
dbDriver, err := db.NewDBDriver(serverProfile)
|
||||
if err != nil {
|
||||
cancel()
|
||||
log.Error("failed to create db driver", zap.Error(err))
|
||||
slog.Error("failed to create db driver", err)
|
||||
return
|
||||
}
|
||||
if err := dbDriver.Migrate(ctx); err != nil {
|
||||
cancel()
|
||||
log.Error("failed to migrate db", zap.Error(err))
|
||||
slog.Error("failed to migrate db", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -54,7 +53,7 @@ var (
|
||||
s, err := server.NewServer(ctx, serverProfile, storeInstance)
|
||||
if err != nil {
|
||||
cancel()
|
||||
log.Error("failed to create server", zap.Error(err))
|
||||
slog.Error("failed to create server", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -70,7 +69,7 @@ var (
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-c
|
||||
log.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||
slog.Info(fmt.Sprintf("%s received.\n", sig.String()))
|
||||
s.Shutdown(ctx)
|
||||
cancel()
|
||||
}()
|
||||
@ -79,7 +78,7 @@ var (
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
log.Error("failed to start server", zap.Error(err))
|
||||
slog.Error("failed to start server", err)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@ -91,7 +90,6 @@ var (
|
||||
)
|
||||
|
||||
func Execute() error {
|
||||
defer log.Sync()
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
@ -142,7 +140,7 @@ func initConfig() {
|
||||
var err error
|
||||
serverProfile, err = profile.GetProfile()
|
||||
if err != nil {
|
||||
log.Error("failed to get profile", zap.Error(err))
|
||||
slog.Error("failed to get profile", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -152,7 +150,6 @@ func initConfig() {
|
||||
println("port:", serverProfile.Port)
|
||||
println("mode:", serverProfile.Mode)
|
||||
println("version:", serverProfile.Version)
|
||||
println("metric:", serverProfile.Metric)
|
||||
println("---")
|
||||
}
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 852 B |
@ -1,52 +1,52 @@
|
||||
{
|
||||
"name": "slash-extension",
|
||||
"displayName": "Slash",
|
||||
"version": "1.0.4",
|
||||
"description": "An open source, self-hosted bookmarks and link sharing platform. Save and share your links very easily.",
|
||||
"version": "1.0.8",
|
||||
"description": "An open source, self-hosted links shortener and sharing platform. Save and share your links very easily.",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"package": "plasmo package",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||
"type-gen": "cd ../../proto && buf generate"
|
||||
"postinstall": "cd ../../proto && buf generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/joy": "5.0.0-beta.23",
|
||||
"@plasmohq/storage": "^1.9.0",
|
||||
"axios": "^1.6.5",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/joy": "5.0.0-beta.32",
|
||||
"@plasmohq/storage": "^1.10.0",
|
||||
"axios": "^1.6.8",
|
||||
"classnames": "^2.5.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.312.0",
|
||||
"plasmo": "^0.83.1",
|
||||
"plasmo": "^0.85.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"zustand": "^4.5.0"
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/buf": "^1.28.1",
|
||||
"@bufbuild/buf": "^1.30.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/chrome": "^0.0.241",
|
||||
"@types/chrome": "^0.0.266",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"long": "^5.2.3",
|
||||
"postcss": "^8.4.33",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^2.8.8",
|
||||
"protobufjs": "^7.2.6",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"manifest": {
|
||||
"permissions": [
|
||||
|
10305
frontend/extension/pnpm-lock.yaml
generated
10305
frontend/extension/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,10 @@
|
||||
import { Button, IconButton, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useShortcutStore from "@/store/shortcut";
|
||||
import { Visibility } from "@/types/proto/api/v2/common";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { useStorageContext } from "@/context";
|
||||
import { useShortcutStore } from "@/stores";
|
||||
import type { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface State {
|
||||
@ -14,14 +14,14 @@ interface State {
|
||||
}
|
||||
|
||||
const CreateShortcutButton = () => {
|
||||
const [instanceUrl] = useStorage("domain");
|
||||
const [accessToken] = useStorage("access_token");
|
||||
const context = useStorageContext();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const [state, setState] = useState<State>({
|
||||
name: "",
|
||||
title: "",
|
||||
link: "",
|
||||
});
|
||||
const [tag, setTag] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
@ -83,6 +83,11 @@ const CreateShortcutButton = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setTag(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
@ -94,21 +99,23 @@ const CreateShortcutButton = () => {
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const tags = tag.split(" ").filter(Boolean);
|
||||
await shortcutStore.createShortcut(
|
||||
instanceUrl,
|
||||
accessToken,
|
||||
context.instanceUrl,
|
||||
context.accessToken,
|
||||
Shortcut.fromPartial({
|
||||
name: state.name,
|
||||
title: state.title,
|
||||
link: state.link,
|
||||
visibility: Visibility.PUBLIC,
|
||||
tags,
|
||||
visibility: context.defaultVisibility as Visibility,
|
||||
})
|
||||
);
|
||||
toast.success("Shortcut created successfully");
|
||||
setShowModal(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
toast.error(error.response.data.message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
@ -157,6 +164,10 @@ const CreateShortcutButton = () => {
|
||||
onChange={handleLinkInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<span className="block w-12 mr-2 shrink-0">Tags</span>
|
||||
<Input className="grow" type="text" placeholder="The tags of shortcut" value={tag} onChange={handleTagsInputChange} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2 space-x-2">
|
||||
<Button color="neutral" variant="plain" onClick={() => setShowModal(false)}>
|
||||
|
@ -1,12 +1,12 @@
|
||||
import classNames from "classnames";
|
||||
import LogoBase64 from "data-base64:../../assets/icon.png";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Logo = ({ className }: Props) => {
|
||||
return <img className={classNames("rounded-full", className)} src={LogoBase64} alt="" />;
|
||||
return <Icon.CircleSlash className={classNames("dark:text-gray-500", className)} strokeWidth={1.5} />;
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
|
@ -1,24 +1,23 @@
|
||||
import { IconButton } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useShortcutStore from "@/store/shortcut";
|
||||
import { useStorageContext } from "@/context";
|
||||
import { useShortcutStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const PullShortcutsButton = () => {
|
||||
const [instanceUrl] = useStorage("domain");
|
||||
const [accessToken] = useStorage("access_token");
|
||||
const context = useStorageContext();
|
||||
const shortcutStore = useShortcutStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceUrl && accessToken) {
|
||||
if (context.instanceUrl && context.accessToken) {
|
||||
handlePullShortcuts(true);
|
||||
}
|
||||
}, [instanceUrl, accessToken]);
|
||||
}, [context]);
|
||||
|
||||
const handlePullShortcuts = async (silence = false) => {
|
||||
try {
|
||||
await shortcutStore.fetchShortcutList(instanceUrl, accessToken);
|
||||
await shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
|
||||
if (!silence) {
|
||||
toast.success("Shortcuts pulled");
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import classNames from "classnames";
|
||||
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||
import type { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import classNames from "classnames";
|
||||
import useShortcutStore from "@/store/shortcut";
|
||||
import { useShortcutStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
|
||||
|
27
frontend/extension/src/context/context.ts
Normal file
27
frontend/extension/src/context/context.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
|
||||
interface Context {
|
||||
instanceUrl?: string;
|
||||
accessToken?: string;
|
||||
defaultVisibility: string;
|
||||
setInstanceUrl: (instanceUrl: string) => void;
|
||||
setAccessToken: (accessToken: string) => void;
|
||||
setDefaultVisibility: (visibility: string) => void;
|
||||
}
|
||||
|
||||
export const StorageContext = createContext<Context>({
|
||||
instanceUrl: undefined,
|
||||
accessToken: undefined,
|
||||
defaultVisibility: Visibility.PRIVATE,
|
||||
setInstanceUrl: () => {},
|
||||
setAccessToken: () => {},
|
||||
setDefaultVisibility: () => {},
|
||||
});
|
||||
|
||||
const useStorageContext = () => {
|
||||
const context = useContext(StorageContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export default useStorageContext;
|
4
frontend/extension/src/context/index.ts
Normal file
4
frontend/extension/src/context/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import useStorageContext from "./context";
|
||||
import StorageContextProvider from "./provider";
|
||||
|
||||
export { useStorageContext, StorageContextProvider };
|
66
frontend/extension/src/context/provider.tsx
Normal file
66
frontend/extension/src/context/provider.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { StorageContext } from "./context";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StorageContextProvider = ({ children }: Props) => {
|
||||
const storage = new Storage();
|
||||
const [instanceUrl, setInstanceUrl] = useState<string | undefined>(undefined);
|
||||
const [accessToken, setAccessToken] = useState<string | undefined>(undefined);
|
||||
const [defaultVisibility, setDefaultVisibility] = useState<Visibility>(Visibility.PRIVATE);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let instanceUrl = await storage.get("instance_url");
|
||||
const accessToken = await storage.get("access_token");
|
||||
const defaultVisibility = (await storage.get("default_visibility")) as Visibility;
|
||||
|
||||
// Migrate domain to instance_url.
|
||||
const domain = await storage.get("domain");
|
||||
if (domain) {
|
||||
instanceUrl = domain;
|
||||
await storage.remove("domain");
|
||||
await storage.set("instance_url", instanceUrl);
|
||||
}
|
||||
|
||||
setInstanceUrl(instanceUrl);
|
||||
setAccessToken(accessToken);
|
||||
setDefaultVisibility(defaultVisibility);
|
||||
setIsInitialized(true);
|
||||
})();
|
||||
|
||||
storage.watch({
|
||||
instance_url: (c) => {
|
||||
setInstanceUrl(c.newValue);
|
||||
},
|
||||
access_token: (c) => {
|
||||
setAccessToken(c.newValue);
|
||||
},
|
||||
default_visibility: (c) => {
|
||||
setDefaultVisibility(c.newValue as Visibility);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider
|
||||
value={{
|
||||
instanceUrl,
|
||||
accessToken,
|
||||
defaultVisibility,
|
||||
setInstanceUrl: (instanceUrl: string) => storage.set("instance_url", instanceUrl),
|
||||
setAccessToken: (accessToken: string) => storage.set("access_token", accessToken),
|
||||
setDefaultVisibility: (visibility: Visibility) => storage.set("default_visibility", visibility),
|
||||
}}
|
||||
>
|
||||
{isInitialized && children}
|
||||
</StorageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageContextProvider;
|
@ -1,9 +1,3 @@
|
||||
import { isNull, isUndefined } from "lodash-es";
|
||||
|
||||
export const isNullorUndefined = (value: any) => {
|
||||
return isNull(value) || isUndefined(value);
|
||||
};
|
||||
|
||||
export const getFaviconWithGoogleS2 = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { Button, CssVarsProvider, Divider, Input, Select, Option } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Toaster, toast } from "react-hot-toast";
|
||||
import { useShortcutStore } from "@/stores";
|
||||
import Icon from "./components/Icon";
|
||||
import Logo from "./components/Logo";
|
||||
import PullShortcutsButton from "./components/PullShortcutsButton";
|
||||
import ShortcutsContainer from "./components/ShortcutsContainer";
|
||||
import { StorageContextProvider, useStorageContext } from "./context";
|
||||
import useColorTheme from "./hooks/useColorTheme";
|
||||
import useShortcutStore from "./store/shortcut";
|
||||
import "./style.css";
|
||||
import { Visibility } from "./types/proto/api/v1/common";
|
||||
|
||||
interface SettingState {
|
||||
domain: string;
|
||||
@ -32,22 +33,21 @@ const colorThemeOptions = [
|
||||
|
||||
const IndexOptions = () => {
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
const [domain, setDomain] = useStorage<string>("domain", (v) => (v ? v : ""));
|
||||
const [accessToken, setAccessToken] = useStorage<string>("access_token", (v) => (v ? v : ""));
|
||||
const context = useStorageContext();
|
||||
const [settingState, setSettingState] = useState<SettingState>({
|
||||
domain,
|
||||
accessToken,
|
||||
domain: context.instanceUrl || "",
|
||||
accessToken: context.accessToken || "",
|
||||
});
|
||||
const shortcutStore = useShortcutStore();
|
||||
const shortcuts = shortcutStore.getShortcutList();
|
||||
const isInitialized = domain && accessToken;
|
||||
const isInitialized = context.instanceUrl && context.accessToken;
|
||||
|
||||
useEffect(() => {
|
||||
setSettingState({
|
||||
domain,
|
||||
accessToken,
|
||||
domain: context.instanceUrl || "",
|
||||
accessToken: context.accessToken || "",
|
||||
});
|
||||
}, [domain, accessToken]);
|
||||
}, [context]);
|
||||
|
||||
const setPartialSettingState = (partialSettingState: Partial<SettingState>) => {
|
||||
setSettingState((prevState) => ({
|
||||
@ -57,8 +57,8 @@ const IndexOptions = () => {
|
||||
};
|
||||
|
||||
const handleSaveSetting = () => {
|
||||
setDomain(settingState.domain);
|
||||
setAccessToken(settingState.accessToken);
|
||||
context.setInstanceUrl(settingState.domain);
|
||||
context.setAccessToken(settingState.accessToken);
|
||||
toast.success("Setting saved");
|
||||
};
|
||||
|
||||
@ -66,6 +66,10 @@ const IndexOptions = () => {
|
||||
setColorTheme(colorTheme as any);
|
||||
};
|
||||
|
||||
const handleDefaultVisibilitySelect = (value: Visibility) => {
|
||||
context.setDefaultVisibility(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full px-4">
|
||||
<div className="w-full flex flex-row justify-center items-center">
|
||||
@ -92,10 +96,10 @@ const IndexOptions = () => {
|
||||
<div className="w-full flex flex-col justify-start items-start mb-4">
|
||||
<div className="mb-2 text-base w-full flex flex-row justify-between items-center">
|
||||
<span className="dark:text-gray-400">Instance URL</span>
|
||||
{domain !== "" && (
|
||||
{context.instanceUrl !== "" && (
|
||||
<a
|
||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||
href={domain}
|
||||
href={context.instanceUrl}
|
||||
target="_blank"
|
||||
>
|
||||
<span className="mr-1">Go to my Slash</span>
|
||||
@ -133,9 +137,9 @@ const IndexOptions = () => {
|
||||
|
||||
<Divider className="!my-6" />
|
||||
|
||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p>
|
||||
<p className="text-base font-semibold leading-6 mb-2 text-gray-900 dark:text-gray-500">Preference</p>
|
||||
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-2">
|
||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||
<span className="dark:text-gray-400">Color Theme</span>
|
||||
</div>
|
||||
@ -149,6 +153,17 @@ const IndexOptions = () => {
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||
<span className="dark:text-gray-400">Default Visibility</span>
|
||||
</div>
|
||||
<Select defaultValue={context.defaultVisibility} onChange={(_, value) => handleDefaultVisibilitySelect(value as Visibility)}>
|
||||
<Option value={Visibility.PRIVATE}>Private</Option>
|
||||
<Option value={Visibility.WORKSPACE}>Workspace</Option>
|
||||
<Option value={Visibility.PUBLIC}>Public</Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInitialized && (
|
||||
@ -170,10 +185,12 @@ const IndexOptions = () => {
|
||||
|
||||
const Options = () => {
|
||||
return (
|
||||
<StorageContextProvider>
|
||||
<CssVarsProvider>
|
||||
<IndexOptions />
|
||||
<Toaster position="top-center" />
|
||||
</CssVarsProvider>
|
||||
</StorageContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Button, CssVarsProvider, Divider, IconButton } from "@mui/joy";
|
||||
import { useStorage } from "@plasmohq/storage/hook";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import CreateShortcutButton from "@/components/CreateShortcutButton";
|
||||
@ -7,24 +6,24 @@ import Icon from "@/components/Icon";
|
||||
import Logo from "@/components/Logo";
|
||||
import PullShortcutsButton from "@/components/PullShortcutsButton";
|
||||
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
||||
import { useShortcutStore } from "@/stores";
|
||||
import { StorageContextProvider, useStorageContext } from "./context";
|
||||
import useColorTheme from "./hooks/useColorTheme";
|
||||
import useShortcutStore from "./store/shortcut";
|
||||
import "./style.css";
|
||||
|
||||
const IndexPopup = () => {
|
||||
useColorTheme();
|
||||
const [instanceUrl] = useStorage<string>("domain", "");
|
||||
const [accessToken] = useStorage<string>("access_token", "");
|
||||
const context = useStorageContext();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const shortcuts = shortcutStore.getShortcutList();
|
||||
const isInitialized = instanceUrl && accessToken;
|
||||
const isInitialized = context.instanceUrl && context.accessToken;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
shortcutStore.fetchShortcutList(instanceUrl, accessToken);
|
||||
shortcutStore.fetchShortcutList(context.instanceUrl, context.accessToken);
|
||||
}, [isInitialized]);
|
||||
|
||||
const handleSettingButtonClick = () => {
|
||||
@ -86,7 +85,7 @@ const IndexPopup = () => {
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<a
|
||||
className="text-sm flex flex-row justify-start items-center underline text-blue-600 hover:opacity-80"
|
||||
href={instanceUrl}
|
||||
href={context.instanceUrl}
|
||||
target="_blank"
|
||||
>
|
||||
<span className="mr-1">Go to my Slash</span>
|
||||
@ -117,10 +116,12 @@ const IndexPopup = () => {
|
||||
|
||||
const Popup = () => {
|
||||
return (
|
||||
<StorageContextProvider>
|
||||
<CssVarsProvider>
|
||||
<IndexPopup />
|
||||
<Toaster position="top-center" />
|
||||
</CssVarsProvider>
|
||||
</StorageContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
3
frontend/extension/src/stores/index.ts
Normal file
3
frontend/extension/src/stores/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import useShortcutStore from "./shortcut";
|
||||
|
||||
export { useShortcutStore };
|
@ -1,7 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { create } from "zustand";
|
||||
import { combine } from "zustand/middleware";
|
||||
import { CreateShortcutResponse, ListShortcutsResponse, Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { CreateShortcutResponse, ListShortcutsResponse, Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
|
||||
interface State {
|
||||
shortcutMapById: Record<number, Shortcut>;
|
||||
@ -18,7 +18,7 @@ const useShortcutStore = create(
|
||||
fetchShortcutList: async (instanceUrl: string, accessToken: string) => {
|
||||
const {
|
||||
data: { shortcuts },
|
||||
} = await axios.get<ListShortcutsResponse>(`${instanceUrl}/api/v2/shortcuts`, {
|
||||
} = await axios.get<ListShortcutsResponse>(`${instanceUrl}/api/v1/shortcuts`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
@ -36,7 +36,7 @@ const useShortcutStore = create(
|
||||
createShortcut: async (instanceUrl: string, accessToken: string, create: Shortcut) => {
|
||||
const {
|
||||
data: { shortcut },
|
||||
} = await axios.post<CreateShortcutResponse>(`${instanceUrl}/api/v2/shortcuts`, create, {
|
||||
} = await axios.post<CreateShortcutResponse>(`${instanceUrl}/api/v1/shortcuts`, create, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
@ -76,7 +76,8 @@
|
||||
"enable-user-signup": {
|
||||
"self": "Enable user signup",
|
||||
"description": "Once enabled, other users can signup."
|
||||
}
|
||||
},
|
||||
"default-visibility": "Default visibility"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,7 +76,8 @@
|
||||
"enable-user-signup": {
|
||||
"self": "启用用户注册",
|
||||
"description": "允许其他用户注册新账号"
|
||||
}
|
||||
},
|
||||
"default-visibility": "默认可见性"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.png" type="image/*" />
|
||||
<link rel="icon" href="/logo.svg" type="image/*" />
|
||||
<meta name="theme-color" content="#FFFFFF" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<!-- slash.metadata -->
|
||||
|
@ -6,50 +6,50 @@
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint --ext .js,.ts,.tsx, src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.tsx, src --fix",
|
||||
"type-gen": "cd ../../proto && buf generate"
|
||||
"postinstall": "cd ../../proto && buf generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/joy": "5.0.0-beta.23",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/joy": "5.0.0-beta.30",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"classnames": "^2.5.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dayjs": "^1.11.10",
|
||||
"i18next": "^23.7.18",
|
||||
"i18next": "^23.11.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.312.0",
|
||||
"nice-grpc-web": "^3.3.2",
|
||||
"nice-grpc-web": "^3.3.3",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-i18next": "^13.5.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"react-use": "^17.4.3",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"zustand": "^4.5.0"
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-use": "^17.5.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/buf": "^1.28.1",
|
||||
"@bufbuild/buf": "^1.30.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"long": "^5.2.3",
|
||||
"postcss": "^8.4.33",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "2.6.2",
|
||||
"protobufjs": "^7.2.6",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.9"
|
||||
},
|
||||
"resolutions": {
|
||||
"csstype": "3.1.2"
|
||||
|
5801
frontend/web/pnpm-lock.yaml
generated
5801
frontend/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 19 KiB |
1
frontend/web/public/logo.svg
Normal file
1
frontend/web/public/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-slash"><line x1="9" x2="15" y1="15" y2="9"/><circle cx="12" cy="12" r="10"/></svg>frontend/web/public/logo.svg
|
After Width: | Height: | Size: 319 B |
@ -1,9 +1,8 @@
|
||||
import { useColorScheme } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import useUserStore from "./stores/v1/user";
|
||||
import useWorkspaceStore from "./stores/v1/workspace";
|
||||
import DemoBanner from "@/components/DemoBanner";
|
||||
import { useUserStore, useWorkspaceStore } from "@/stores";
|
||||
|
||||
function App() {
|
||||
const { mode: colorScheme } = useColorScheme();
|
||||
|
@ -21,7 +21,7 @@ const AboutDialog: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
<div className="max-w-full w-80 sm:w-96">
|
||||
<p>
|
||||
<span className="font-medium">Slash</span>: An open source, self-hosted bookmarks and link sharing platform.
|
||||
<span className="font-medium">Slash</span>: An open source, self-hosted links shortener and sharing platform.
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<span className="mr-2">See more in</span>
|
||||
|
@ -2,7 +2,7 @@ import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { shortcutServiceClient } from "@/grpcweb";
|
||||
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { GetShortcutAnalyticsResponse } from "@/types/proto/api/v1/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -23,7 +23,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames("w-full", className)}>
|
||||
<div className={classNames("relative w-full", className)}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<div className="w-full">
|
||||
@ -138,7 +138,7 @@ const AnalyticsView: React.FC<Props> = (props: Props) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||
<div className="absolute py-12 w-full flex flex-row justify-center items-center opacity-80">
|
||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
|
@ -7,11 +7,9 @@ import { Link } from "react-router-dom";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import useCollectionStore from "@/stores/v1/collection";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import useUserStore from "@/stores/v1/user";
|
||||
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { useCollectionStore, useShortcutStore, useUserStore } from "@/stores";
|
||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { showCommonDialog } from "./Alert";
|
||||
import CreateCollectionDialog from "./CreateCollectionDrawer";
|
||||
import Icon from "./Icon";
|
||||
|
@ -3,8 +3,8 @@ import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
|
@ -3,13 +3,12 @@ import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useCollectionStore from "@/stores/v1/collection";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||
import { Visibility } from "@/types/proto/api/v2/common";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useCollectionStore, useShortcutStore, useWorkspaceStore } from "@/stores";
|
||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
|
||||
@ -26,6 +25,7 @@ interface State {
|
||||
const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
const { onClose, onConfirm, collectionId } = props;
|
||||
const { t } = useTranslation();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const collectionStore = useCollectionStore();
|
||||
const shortcutList = useShortcutStore().getShortcutList();
|
||||
const [state, setState] = useState<State>({
|
||||
@ -49,6 +49,23 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
})
|
||||
.filter((shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id));
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
||||
setPartialState({
|
||||
collectionCreate: Object.assign(state.collectionCreate, {
|
||||
visibility: workspaceStore.setting.defaultVisibility,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (collectionId) {
|
||||
@ -75,13 +92,6 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
collectionCreate: Object.assign(state.collectionCreate, {
|
||||
@ -101,7 +111,7 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
collectionCreate: Object.assign(state.collectionCreate, {
|
||||
visibility: Number(e.target.value),
|
||||
visibility: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@ -159,8 +169,8 @@ const CreateCollectionDrawer: React.FC<Props> = (props: Props) => {
|
||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||
<DialogTitle>{isCreating ? "Create Collection" : "Edit Collection"}</DialogTitle>
|
||||
<ModalClose />
|
||||
<DialogContent className="w-full max-w-full sm:max-w-[24rem]">
|
||||
<div className="overflow-y-auto w-full mt-2 px-3 pb-4">
|
||||
<DialogContent className="w-full max-w-full">
|
||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
|
@ -16,11 +16,12 @@ import { isUndefined, uniq } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useShortcutStore, { getShortcutUpdateMask } from "@/stores/v1/shortcut";
|
||||
import { Visibility } from "@/types/proto/api/v2/common";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useWorkspaceStore, useShortcutStore } from "@/stores";
|
||||
import { getShortcutUpdateMask } from "@/stores/shortcut";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -49,6 +50,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
}),
|
||||
});
|
||||
const shortcutStore = useShortcutStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState<boolean>(false);
|
||||
const shortcutList = shortcutStore.getShortcutList();
|
||||
const [tag, setTag] = useState<string>("");
|
||||
@ -57,6 +59,23 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
const loadingState = useLoading(!isCreating);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: workspaceStore.setting.defaultVisibility,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcut = shortcutStore.getShortcutById(shortcutId);
|
||||
@ -82,13 +101,6 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
@ -116,7 +128,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
shortcutCreate: Object.assign(state.shortcutCreate, {
|
||||
visibility: Number(e.target.value),
|
||||
visibility: e.target.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@ -213,8 +225,8 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
<Drawer anchor="right" open={true} onClose={onClose}>
|
||||
<DialogTitle>{isCreating ? "Create Shortcut" : "Edit Shortcut"}</DialogTitle>
|
||||
<ModalClose />
|
||||
<DialogContent className="w-full max-w-full sm:max-w-[24rem]">
|
||||
<div className="overflow-y-auto w-full mt-2 px-3 pb-4">
|
||||
<DialogContent className="w-full max-w-full">
|
||||
<div className="overflow-y-auto w-full mt-2 px-4 pb-4 sm:w-[24rem]">
|
||||
<div className="w-full flex flex-col justify-start items-start mb-3">
|
||||
<span className="mb-2">
|
||||
Name <span className="text-red-600">*</span>
|
||||
@ -327,7 +339,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
<Input
|
||||
className="w-full"
|
||||
type="text"
|
||||
placeholder="Slash - An open source, self-hosted bookmarks and link sharing platform"
|
||||
placeholder="Slash - An open source, self-hosted links shortener and sharing platform"
|
||||
size="sm"
|
||||
value={state.shortcutCreate.ogMetadata?.title}
|
||||
onChange={handleOpenGraphMetadataTitleChange}
|
||||
@ -337,7 +349,7 @@ const CreateShortcutDrawer: React.FC<Props> = (props: Props) => {
|
||||
<span className="mb-2 text-sm">Description</span>
|
||||
<Textarea
|
||||
className="w-full"
|
||||
placeholder="An open source, self-hosted bookmarks and link sharing platform."
|
||||
placeholder="An open source, self-hosted links shortener and sharing platform."
|
||||
size="sm"
|
||||
maxRows={3}
|
||||
value={state.shortcutCreate.ogMetadata?.description}
|
||||
|
@ -3,9 +3,9 @@ import { isUndefined } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Role, User } from "@/types/proto/api/v2/user_service";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { Role, User } from "@/types/proto/api/v1/user_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const DemoBanner: React.FC = () => {
|
||||
@ -10,7 +10,7 @@ const DemoBanner: React.FC = () => {
|
||||
return (
|
||||
<div className="z-10 relative flex flex-row items-center justify-center w-full py-2 text-sm sm:text-lg font-medium dark:text-gray-300 bg-white dark:bg-zinc-700 shadow">
|
||||
<div className="w-full max-w-8xl px-4 md:px-12 flex flex-row justify-between items-center gap-x-3">
|
||||
<span>✨🔗 Slash - An open source, self-hosted bookmarks and link sharing platform</span>
|
||||
<span>✨🔗 Slash - An open source, self-hosted links shortener and sharing platform</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/yourselfhosted/slash#deploy-with-docker-in-seconds"
|
||||
|
@ -2,8 +2,8 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useUserStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewStore } from "@/stores";
|
||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
||||
|
@ -3,8 +3,8 @@ import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
@ -25,12 +25,12 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||
const handleDownloadQRCodeClick = () => {
|
||||
const canvas = containerRef.current?.querySelector("canvas");
|
||||
if (!canvas) {
|
||||
toast.error("Failed to get qr code canvas");
|
||||
toast.error("Failed to get QR code canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.download = "filename.png";
|
||||
link.download = `${shortcut.title || shortcut.name}-qrcode.png`;
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
handleCloseBtnClick();
|
||||
@ -47,7 +47,7 @@ const GenerateQRCodeDialog: React.FC<Props> = (props: Props) => {
|
||||
</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"} />
|
||||
<QRCodeCanvas value={shortcutLink} size={180} 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}>
|
||||
|
@ -3,10 +3,9 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { authServiceClient } from "@/grpcweb";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||
import { Role } from "@/types/proto/api/v2/user_service";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { useWorkspaceStore, useUserStore } from "@/stores";
|
||||
import { PlanType } from "@/types/proto/api/v1/subscription_service";
|
||||
import { Role } from "@/types/proto/api/v1/user_service";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
@ -19,8 +18,8 @@ const Header: React.FC = () => {
|
||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||
const profile = workspaceStore.profile;
|
||||
const isAdmin = currentUser.role === Role.ADMIN;
|
||||
const shouldShowRouterSwitch = location.pathname === "/" || location.pathname === "/collections" || location.pathname === "/memos";
|
||||
const selectedSection = location.pathname === "/" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "Memos";
|
||||
const shouldShowRouterSwitch = location.pathname === "/shortcuts" || location.pathname === "/collections";
|
||||
const selectedSection = location.pathname === "/shortcuts" ? "Shortcuts" : location.pathname === "/collections" ? "Collections" : "";
|
||||
|
||||
const handleSignOutButtonClick = async () => {
|
||||
await authServiceClient.signOut({});
|
||||
@ -30,10 +29,10 @@ const Header: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
|
||||
<div className="w-full max-w-8xl mx-auto px-3 md:px-12 py-3 flex flex-row justify-between items-center">
|
||||
<div className="w-full max-w-8xl mx-auto px-4 sm:px-6 md:px-12 py-3 flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||
<Link to="/" className="cursor-pointer flex flex-row justify-start items-center dark:text-gray-400" unstable_viewTransition>
|
||||
<img id="logo-img" src="/logo.png" className="w-7 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
|
||||
<Icon.CircleSlash className="w-7 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} />
|
||||
Slash
|
||||
</Link>
|
||||
{profile.plan === PlanType.PRO && (
|
||||
@ -56,7 +55,7 @@ const Header: React.FC = () => {
|
||||
<>
|
||||
<Link
|
||||
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
|
||||
to="/"
|
||||
to="/shortcuts"
|
||||
unstable_viewTransition
|
||||
>
|
||||
<Icon.SquareSlash className="w-5 h-auto mr-2 opacity-70" /> Shortcuts
|
||||
|
36
frontend/web/src/components/LinkFavicon.tsx
Normal file
36
frontend/web/src/components/LinkFavicon.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const getFaviconUrlWithProvider = (url: string, provider: string) => {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("domain", new URL(url).hostname);
|
||||
return new URL(`?${searchParams.toString()}`, provider).toString();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const LinkFavicon = (props: Props) => {
|
||||
const { url } = props;
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const faviconProvider = workspaceStore.profile.faviconProvider || "https://www.google.com/s2/favicons";
|
||||
const [faviconUrl, setFaviconUrl] = useState<string>(getFaviconUrlWithProvider(url, faviconProvider));
|
||||
|
||||
const handleImgError = () => {
|
||||
setFaviconUrl("");
|
||||
};
|
||||
|
||||
return faviconUrl ? (
|
||||
<img className="w-full h-auto rounded" src={faviconUrl} decoding="async" loading="lazy" onError={handleImgError} />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" strokeWidth={1.5} />
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkFavicon;
|
@ -1,10 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { Role } from "@/types/proto/api/v2/user_service";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { useShortcutStore, useUserStore } from "@/stores";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { Role } from "@/types/proto/api/v1/user_service";
|
||||
import { showCommonDialog } from "./Alert";
|
||||
import CreateShortcutDrawer from "./CreateShortcutDrawer";
|
||||
import GenerateQRCodeDialog from "./GenerateQRCodeDialog";
|
||||
|
@ -5,12 +5,12 @@ import { useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import useUserStore from "@/stores/v1/user";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { useUserStore, useViewStore } from "@/stores";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import Icon from "./Icon";
|
||||
import LinkFavicon from "./LinkFavicon";
|
||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
||||
@ -25,7 +25,6 @@ const ShortcutCard = (props: Props) => {
|
||||
const viewStore = useViewStore();
|
||||
const creator = userStore.getUserById(shortcut.creatorId);
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||
|
||||
useEffect(() => {
|
||||
userStore.getOrFetchUserById(shortcut.creatorId);
|
||||
@ -49,17 +48,13 @@ const ShortcutCard = (props: Props) => {
|
||||
to={`/shortcut/${shortcut.id}`}
|
||||
unstable_viewTransition
|
||||
>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
<LinkFavicon url={shortcut.link} />
|
||||
</Link>
|
||||
<div className="ml-1 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<div className="ml-2 w-[calc(100%-24px)] flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center leading-tight">
|
||||
<a
|
||||
className={classNames(
|
||||
"max-w-[calc(100%-36px)] flex flex-row px-1 mr-1 justify-start items-center cursor-pointer rounded-md hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
|
||||
"max-w-[calc(100%-36px)] flex flex-row justify-start items-center mr-1 cursor-pointer hover:opacity-80 hover:underline"
|
||||
)}
|
||||
target="_blank"
|
||||
href={shortcutLink}
|
||||
@ -69,9 +64,7 @@ const ShortcutCard = (props: Props) => {
|
||||
{shortcut.title ? (
|
||||
<span className="text-gray-500">({shortcut.name})</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden group-hover:block ml-1 cursor-pointer shrink-0">
|
||||
@ -80,7 +73,7 @@ const ShortcutCard = (props: Props) => {
|
||||
</a>
|
||||
<Tooltip title="Copy" variant="solid" placement="top" arrow>
|
||||
<button
|
||||
className="hidden group-hover:block w-6 h-6 cursor-pointer rounded-md text-gray-500 hover:bg-gray-100 hover:shadow dark:hover:bg-zinc-800"
|
||||
className="hidden group-hover:block cursor-pointer text-gray-500 hover:opacity-80"
|
||||
onClick={() => handleCopyButtonClick()}
|
||||
>
|
||||
<Icon.Clipboard className="w-4 h-auto mx-auto" />
|
||||
@ -88,7 +81,7 @@ const ShortcutCard = (props: Props) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<a
|
||||
className="pl-1 pr-4 w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
|
||||
className="pr-4 leading-tight w-full text-sm truncate text-gray-400 dark:text-gray-500 hover:underline"
|
||||
href={shortcut.link}
|
||||
target="_blank"
|
||||
>
|
||||
|
@ -1,17 +1,15 @@
|
||||
import { Divider } from "@mui/joy";
|
||||
import classNames from "classnames";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getFaviconWithGoogleS2 } from "@/helpers/utils";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
import LinkFavicon from "./LinkFavicon";
|
||||
|
||||
interface Props {
|
||||
shortcut: Shortcut;
|
||||
}
|
||||
|
||||
const ShortcutFrame = ({ shortcut }: Props) => {
|
||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-center items-center p-8">
|
||||
<Link
|
||||
@ -20,11 +18,7 @@ const ShortcutFrame = ({ shortcut }: Props) => {
|
||||
target="_blank"
|
||||
>
|
||||
<div className={classNames("w-12 h-12 flex justify-center items-center overflow-clip rounded-lg shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.Globe2Icon className="w-full h-auto opacity-70" strokeWidth={1} />
|
||||
)}
|
||||
<LinkFavicon url={shortcut.link} />
|
||||
</div>
|
||||
<p className="text-lg font-medium leading-8 mt-2 truncate">{shortcut.title || shortcut.name}</p>
|
||||
<p className="text-gray-500 truncate">{shortcut.description}</p>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import classNames from "classnames";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import Icon from "./Icon";
|
||||
import LinkFavicon from "./LinkFavicon";
|
||||
import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
||||
|
||||
interface Props {
|
||||
@ -15,7 +15,6 @@ interface Props {
|
||||
|
||||
const ShortcutView = (props: Props) => {
|
||||
const { shortcut, className, showActions, alwaysShowLink, onClick } = props;
|
||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -26,11 +25,7 @@ const ShortcutView = (props: Props) => {
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
|
||||
)}
|
||||
<LinkFavicon url={shortcut.link} />
|
||||
</div>
|
||||
<div className="ml-2 w-full truncate">
|
||||
{shortcut.title ? (
|
||||
|
@ -1,7 +1,7 @@
|
||||
import classNames from "classnames";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import { useViewStore } from "@/stores";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import ShortcutCard from "./ShortcutCard";
|
||||
import ShortcutView from "./ShortcutView";
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import { useShortcutStore, useViewStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const ShortcutsNavigator = () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Divider, Option, Select, Switch } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useViewStore from "../stores/v1/view";
|
||||
import { useViewStore } from "@/stores";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./common/Dropdown";
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Visibility } from "@/types/proto/api/v2/common";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import Icon from "./Icon";
|
||||
|
||||
interface Props {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import Icon from "../Icon";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface Props {
|
||||
trigger?: ReactNode;
|
||||
|
@ -3,12 +3,12 @@ import copy from "copy-to-clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showCommonDialog } from "@/components/Alert";
|
||||
import CreateAccessTokenDialog from "@/components/CreateAccessTokenDialog";
|
||||
import Icon from "@/components/Icon";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import { UserAccessToken } from "@/types/proto/api/v2/user_service";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import { showCommonDialog } from "../Alert";
|
||||
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||
import Icon from "../Icon";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { UserAccessToken } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
const listAccessTokens = async (userId: number) => {
|
||||
const { accessTokens } = await userServiceClient.listUserAccessTokens({
|
||||
@ -64,7 +64,7 @@ const AccessTokenSection = () => {
|
||||
<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 dark:text-gray-500">Access Tokens</p>
|
||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">Access Tokens</p>
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">A list of all access tokens for your account.</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Role } from "@/types/proto/api/v2/user_service";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import ChangePasswordDialog from "../ChangePasswordDialog";
|
||||
import EditUserinfoDialog from "../EditUserinfoDialog";
|
||||
import ChangePasswordDialog from "@/components/ChangePasswordDialog";
|
||||
import EditUserinfoDialog from "@/components/EditUserinfoDialog";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { Role } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -16,7 +16,7 @@ const AccountSection: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("common.account")}</p>
|
||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("common.account")}</p>
|
||||
<p className="flex flex-row justify-start items-center mt-2 dark:text-gray-400">
|
||||
<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>}
|
||||
|
@ -2,12 +2,12 @@ import { Button, IconButton } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { User } from "@/types/proto/api/v2/user_service";
|
||||
import { showCommonDialog } from "@/components/Alert";
|
||||
import CreateUserDialog from "@/components/CreateUserDialog";
|
||||
import Icon from "@/components/Icon";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { User } from "@/types/proto/api/v1/user_service";
|
||||
import { convertRoleFromPb } from "@/utils/user";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import { showCommonDialog } from "../Alert";
|
||||
import CreateUserDialog from "../CreateUserDialog";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const MemberSection = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -47,7 +47,7 @@ const MemberSection = () => {
|
||||
<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 dark:text-gray-500">{t("user.self")}</p>
|
||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("user.self")}</p>
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-600">
|
||||
A list of all the users in your workspace including their nickname, email and role.
|
||||
</p>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { Option, Select } from "@mui/joy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
||||
import useUserStore from "../../stores/v1/user";
|
||||
import BetaBadge from "../BetaBadge";
|
||||
import BetaBadge from "@/components/BetaBadge";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { UserSetting, UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v1/user_setting_service";
|
||||
|
||||
const PreferenceSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -58,9 +58,9 @@ const PreferenceSection: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</p>
|
||||
<div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
|
||||
<p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.preference.self")}</p>
|
||||
<div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||
<span className="dark:text-gray-400">{t("settings.preference.color-theme")}</span>
|
||||
@ -91,7 +91,7 @@ const PreferenceSection: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { Button, Checkbox, Input, Textarea } from "@mui/joy";
|
||||
import { Button, Input, Select, Textarea, Option, Switch, Link } from "@mui/joy";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { workspaceServiceClient } from "@/grpcweb";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import { WorkspaceSetting } from "@/types/proto/api/v2/workspace_service";
|
||||
import { useWorkspaceStore } from "@/stores";
|
||||
import { Visibility } from "@/types/proto/api/v1/common";
|
||||
import { WorkspaceSetting } from "@/types/proto/api/v1/workspace_service";
|
||||
|
||||
const WorkspaceSection: React.FC = () => {
|
||||
const WorkspaceSection = () => {
|
||||
const { t } = useTranslation();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting);
|
||||
@ -28,6 +29,13 @@ const WorkspaceSection: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFaviconProvierChange = async (value: string) => {
|
||||
setWorkspaceSetting({
|
||||
...workspaceSetting,
|
||||
faviconProvider: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomStyleChange = async (value: string) => {
|
||||
setWorkspaceSetting({
|
||||
...workspaceSetting,
|
||||
@ -35,6 +43,13 @@ const WorkspaceSection: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDefaultVisibilityChange = async (value: Visibility) => {
|
||||
setWorkspaceSetting({
|
||||
...workspaceSetting,
|
||||
defaultVisibility: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveWorkspaceSetting = async () => {
|
||||
const updateMask: string[] = [];
|
||||
if (!isEqual(originalWorkspaceSetting.current.enableSignup, workspaceSetting.enableSignup)) {
|
||||
@ -46,6 +61,12 @@ const WorkspaceSection: React.FC = () => {
|
||||
if (!isEqual(originalWorkspaceSetting.current.customStyle, workspaceSetting.customStyle)) {
|
||||
updateMask.push("custom_style");
|
||||
}
|
||||
if (!isEqual(originalWorkspaceSetting.current.defaultVisibility, workspaceSetting.defaultVisibility)) {
|
||||
updateMask.push("default_visibility");
|
||||
}
|
||||
if (!isEqual(originalWorkspaceSetting.current.faviconProvider, workspaceSetting.faviconProvider)) {
|
||||
updateMask.push("favicon_provider");
|
||||
}
|
||||
if (updateMask.length === 0) {
|
||||
toast.error("No changes made");
|
||||
return;
|
||||
@ -59,6 +80,7 @@ const WorkspaceSection: React.FC = () => {
|
||||
})
|
||||
).setting as WorkspaceSetting;
|
||||
setWorkspaceSetting(setting);
|
||||
await workspaceStore.fetchWorkspaceSetting();
|
||||
originalWorkspaceSetting.current = setting;
|
||||
toast.success("Workspace setting saved successfully");
|
||||
} catch (error: any) {
|
||||
@ -67,10 +89,11 @@ const WorkspaceSection: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
|
||||
<div className="w-full flex flex-col sm:flex-row justify-start items-start gap-4 sm:gap-x-16">
|
||||
<p className="sm:w-1/4 text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">{t("settings.workspace.self")}</p>
|
||||
<div className="w-full sm:w-auto grow flex flex-col justify-start items-start gap-4">
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="mt-2 dark:text-gray-400">Instance URL</p>
|
||||
<p className="font-medium dark:text-gray-400">Instance URL</p>
|
||||
<Input
|
||||
className="w-full mt-2"
|
||||
placeholder="Your instance URL. Using for website SEO. Leave it empty if you don't want cawler to index your website."
|
||||
@ -79,7 +102,22 @@ const WorkspaceSection: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="mt-2 dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
|
||||
<p className="font-medium dark:text-gray-400">Favicon Provider</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
e.g.{" "}
|
||||
<Link className="!text-sm" href="https://github.com/yourselfhosted/favicons" target="_blank">
|
||||
yourselfhosted/favicons
|
||||
</Link>
|
||||
</p>
|
||||
<Input
|
||||
className="w-full mt-2"
|
||||
placeholder="The provider of favicon. Empty for default Google S2."
|
||||
value={workspaceSetting.faviconProvider}
|
||||
onChange={(event) => handleFaviconProvierChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<p className="mt-2 font-medium dark:text-gray-400">{t("settings.workspace.custom-style")}</p>
|
||||
<Textarea
|
||||
className="w-full mt-2"
|
||||
placeholder="* {font-family: ui-monospace Monaco Consolas;}"
|
||||
@ -89,13 +127,31 @@ const WorkspaceSection: React.FC = () => {
|
||||
onChange={(event) => handleCustomStyleChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<Checkbox
|
||||
label={t("settings.workspace.enable-user-signup.self")}
|
||||
<p className="font-medium">{t("settings.workspace.enable-user-signup.self")}</p>
|
||||
<p className="text-gray-500">{t("settings.workspace.enable-user-signup.description")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={workspaceSetting.enableSignup}
|
||||
onChange={(event) => handleEnableSignUpChange(event.target.checked)}
|
||||
/>
|
||||
<p className="mt-2 text-gray-500">{t("settings.workspace.enable-user-signup.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||
<span className="font-medium dark:text-gray-400">{t("settings.workspace.default-visibility")}</span>
|
||||
</div>
|
||||
<Select
|
||||
defaultValue={workspaceSetting.defaultVisibility || Visibility.PRIVATE}
|
||||
onChange={(_, value) => handleDefaultVisibilityChange(value as Visibility)}
|
||||
>
|
||||
<Option value={Visibility.PRIVATE}>{t(`shortcut.visibility.private.self`)}</Option>
|
||||
<Option value={Visibility.WORKSPACE}>{t(`shortcut.visibility.workspace.self`)}</Option>
|
||||
<Option value={Visibility.PUBLIC}>{t(`shortcut.visibility.public.self`)}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outlined" color="neutral" disabled={!allowSave} onClick={handleSaveWorkspaceSetting}>
|
||||
@ -103,6 +159,7 @@ const WorkspaceSection: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
|
||||
import { AuthServiceDefinition } from "./types/proto/api/v2/auth_service";
|
||||
import { CollectionServiceDefinition } from "./types/proto/api/v2/collection_service";
|
||||
import { ShortcutServiceDefinition } from "./types/proto/api/v2/shortcut_service";
|
||||
import { SubscriptionServiceDefinition } from "./types/proto/api/v2/subscription_service";
|
||||
import { UserServiceDefinition } from "./types/proto/api/v2/user_service";
|
||||
import { UserSettingServiceDefinition } from "./types/proto/api/v2/user_setting_service";
|
||||
import { WorkspaceServiceDefinition } from "./types/proto/api/v2/workspace_service";
|
||||
import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service";
|
||||
import { CollectionServiceDefinition } from "./types/proto/api/v1/collection_service";
|
||||
import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service";
|
||||
import { SubscriptionServiceDefinition } from "./types/proto/api/v1/subscription_service";
|
||||
import { UserServiceDefinition } from "./types/proto/api/v1/user_service";
|
||||
import { UserSettingServiceDefinition } from "./types/proto/api/v1/user_setting_service";
|
||||
import { WorkspaceServiceDefinition } from "./types/proto/api/v1/workspace_service";
|
||||
|
||||
const address = import.meta.env.MODE === "development" ? "http://localhost:8082" : window.location.origin;
|
||||
|
||||
|
@ -1,9 +1,3 @@
|
||||
import { isNull, isUndefined } from "lodash-es";
|
||||
|
||||
export const isNullorUndefined = (value: any) => {
|
||||
return isNull(value) || isUndefined(value);
|
||||
};
|
||||
|
||||
export const absolutifyLink = (rel: string): string => {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.setAttribute("href", rel);
|
||||
@ -15,19 +9,6 @@ export const isURL = (str: string): boolean => {
|
||||
return urlRegex.test(str);
|
||||
};
|
||||
|
||||
export const releaseGuard = () => {
|
||||
return import.meta.env.MODE === "development";
|
||||
};
|
||||
|
||||
export const getFaviconWithGoogleS2 = (url: string) => {
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateRandomString = () => {
|
||||
const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let randomString = "";
|
||||
|
@ -3,11 +3,11 @@ import { isEqual } from "lodash-es";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import Header from "@/components/Header";
|
||||
import Navigator from "@/components/Navigator";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
||||
import Header from "../components/Header";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v1/user_setting_service";
|
||||
|
||||
const Root: React.FC = () => {
|
||||
const navigateTo = useNavigateTo();
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import CollectionView from "@/components/CollectionView";
|
||||
import CreateCollectionDrawer from "@/components/CreateCollectionDrawer";
|
||||
import useCollectionStore from "@/stores/v1/collection";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import FilterView from "../components/FilterView";
|
||||
import Icon from "../components/Icon";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import FilterView from "@/components/FilterView";
|
||||
import Icon from "@/components/Icon";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useShortcutStore, useCollectionStore } from "@/stores";
|
||||
|
||||
interface State {
|
||||
showCreateCollectionDrawer: boolean;
|
||||
@ -15,6 +15,7 @@ interface State {
|
||||
|
||||
const CollectionDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [, setLastVisited] = useLocalStorage<string>("lastVisited", "/shortcuts");
|
||||
const loadingState = useLoading();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const collectionStore = useCollectionStore();
|
||||
@ -31,6 +32,7 @@ const CollectionDashboard: React.FC = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLastVisited("/collections");
|
||||
Promise.all([shortcutStore.fetchShortcutList(), collectionStore.fetchCollectionList()]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
@ -45,7 +47,7 @@ const CollectionDashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="mx-auto max-w-8xl w-full px-4 sm:px-6 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<div>
|
||||
<Input
|
||||
|
@ -7,11 +7,9 @@ import Icon from "@/components/Icon";
|
||||
import ShortcutFrame from "@/components/ShortcutFrame";
|
||||
import ShortcutView from "@/components/ShortcutView";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import useCollectionStore from "@/stores/v1/collection";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import useUserStore from "@/stores/v1/user";
|
||||
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { useUserStore, useCollectionStore, useShortcutStore } from "@/stores";
|
||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
|
||||
const CollectionSpace = () => {
|
||||
const params = useParams();
|
||||
|
@ -1,93 +1,20 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import CreateShortcutDrawer from "../components/CreateShortcutDrawer";
|
||||
import FilterView from "../components/FilterView";
|
||||
import Icon from "../components/Icon";
|
||||
import ShortcutsContainer from "../components/ShortcutsContainer";
|
||||
import ShortcutsNavigator from "../components/ShortcutsNavigator";
|
||||
import ViewSetting from "../components/ViewSetting";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import useViewStore, { getFilteredShortcutList, getOrderedShortcutList } from "../stores/v1/view";
|
||||
|
||||
interface State {
|
||||
showCreateShortcutDrawer: boolean;
|
||||
}
|
||||
import { useEffect } from "react";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const viewStore = useViewStore();
|
||||
const shortcutList = shortcutStore.getShortcutList();
|
||||
const [state, setState] = useState<State>({
|
||||
showCreateShortcutDrawer: false,
|
||||
});
|
||||
const filter = viewStore.filter;
|
||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||
const [lastVisited] = useLocalStorage<string>("lastVisited", "/shortcuts");
|
||||
const navigateTo = useNavigateTo();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([shortcutStore.fetchShortcutList()]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
if (lastVisited === "/shortcuts" || lastVisited === "/collections") {
|
||||
navigateTo(lastVisited);
|
||||
} else {
|
||||
navigateTo("/shortcuts");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setShowCreateShortcutDrawer = (show: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
showCreateShortcutDrawer: show,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<ShortcutsNavigator />
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Input
|
||||
className="w-32 mr-2"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder={t("common.search")}
|
||||
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||
endDecorator={<ViewSetting />}
|
||||
value={filter.search}
|
||||
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDrawer(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
<span className="ml-0.5">{t("common.create")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterView />
|
||||
{loadingState.isLoading ? (
|
||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80 dark:text-gray-500">
|
||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : orderedShortcutList.length === 0 ? (
|
||||
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400">
|
||||
<Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
|
||||
<p className="mt-4">No shortcuts found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ShortcutsContainer shortcutList={orderedShortcutList} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.showCreateShortcutDrawer && (
|
||||
<CreateShortcutDrawer onClose={() => setShowCreateShortcutDrawer(false)} onConfirm={() => setShowCreateShortcutDrawer(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
@ -1,96 +0,0 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CollectionView from "@/components/CollectionView";
|
||||
import CreateCollectionDrawer from "@/components/CreateCollectionDrawer";
|
||||
import useCollectionStore from "@/stores/v1/collection";
|
||||
import FilterView from "../components/FilterView";
|
||||
import Icon from "../components/Icon";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
|
||||
interface State {
|
||||
showCreateCollectionDrawer: boolean;
|
||||
}
|
||||
|
||||
const MemoDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const collectionStore = useCollectionStore();
|
||||
const [state, setState] = useState<State>({
|
||||
showCreateCollectionDrawer: false,
|
||||
});
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const filteredCollections = collectionStore.getCollectionList().filter((collection) => {
|
||||
return (
|
||||
collection.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
collection.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
collection.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([collectionStore.fetchCollectionList()]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setShowCreateCollectionDrawer = (show: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
showCreateCollectionDrawer: show,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<div>
|
||||
<Input
|
||||
className="w-32 mr-2"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder={t("common.search")}
|
||||
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateCollectionDrawer(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
<span className="ml-0.5">{t("common.create")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterView />
|
||||
{loadingState.isLoading ? (
|
||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80 dark:text-gray-500">
|
||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400">
|
||||
<Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
|
||||
<p className="mt-4">No collections found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex flex-col justify-start items-start gap-3">
|
||||
{filteredCollections.map((collection) => {
|
||||
return <CollectionView key={collection.id} collection={collection} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.showCreateCollectionDrawer && (
|
||||
<CreateCollectionDrawer
|
||||
onClose={() => setShowCreateCollectionDrawer(false)}
|
||||
onConfirm={() => setShowCreateCollectionDrawer(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoDashboard;
|
95
frontend/web/src/pages/ShortcutDashboard.tsx
Normal file
95
frontend/web/src/pages/ShortcutDashboard.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { Button, Input } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import CreateShortcutDrawer from "@/components/CreateShortcutDrawer";
|
||||
import FilterView from "@/components/FilterView";
|
||||
import Icon from "@/components/Icon";
|
||||
import ShortcutsContainer from "@/components/ShortcutsContainer";
|
||||
import ShortcutsNavigator from "@/components/ShortcutsNavigator";
|
||||
import ViewSetting from "@/components/ViewSetting";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { useShortcutStore, useUserStore, useViewStore } from "@/stores";
|
||||
import { getFilteredShortcutList, getOrderedShortcutList } from "@/stores/view";
|
||||
|
||||
interface State {
|
||||
showCreateShortcutDrawer: boolean;
|
||||
}
|
||||
|
||||
const ShortcutDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [, setLastVisited] = useLocalStorage<string>("lastVisited", "/shortcuts");
|
||||
const loadingState = useLoading();
|
||||
const currentUser = useUserStore().getCurrentUser();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const viewStore = useViewStore();
|
||||
const shortcutList = shortcutStore.getShortcutList();
|
||||
const [state, setState] = useState<State>({
|
||||
showCreateShortcutDrawer: false,
|
||||
});
|
||||
const filter = viewStore.filter;
|
||||
const filteredShortcutList = getFilteredShortcutList(shortcutList, filter, currentUser);
|
||||
const orderedShortcutList = getOrderedShortcutList(filteredShortcutList, viewStore.order);
|
||||
|
||||
useEffect(() => {
|
||||
setLastVisited("/shortcuts");
|
||||
Promise.all([shortcutStore.fetchShortcutList()]).finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setShowCreateShortcutDrawer = (show: boolean) => {
|
||||
setState({
|
||||
...state,
|
||||
showCreateShortcutDrawer: show,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-8xl w-full px-4 sm:px-6 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<ShortcutsNavigator />
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<Input
|
||||
className="w-32 mr-2"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder={t("common.search")}
|
||||
startDecorator={<Icon.Search className="w-4 h-auto" />}
|
||||
endDecorator={<ViewSetting />}
|
||||
value={filter.search}
|
||||
onChange={(e) => viewStore.setFilter({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDrawer(true)}>
|
||||
<Icon.Plus className="w-5 h-auto" />
|
||||
<span className="ml-0.5">{t("common.create")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FilterView />
|
||||
{loadingState.isLoading ? (
|
||||
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80 dark:text-gray-500">
|
||||
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : orderedShortcutList.length === 0 ? (
|
||||
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400">
|
||||
<Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
|
||||
<p className="mt-4">No shortcuts found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ShortcutsContainer shortcutList={orderedShortcutList} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.showCreateShortcutDrawer && (
|
||||
<CreateShortcutDrawer onClose={() => setShowCreateShortcutDrawer(false)} onConfirm={() => setShowCreateShortcutDrawer(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutDashboard;
|
@ -5,21 +5,21 @@ import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { showCommonDialog } from "@/components/Alert";
|
||||
import AnalyticsView from "@/components/AnalyticsView";
|
||||
import CreateShortcutDrawer from "@/components/CreateShortcutDrawer";
|
||||
import GenerateQRCodeDialog from "@/components/GenerateQRCodeDialog";
|
||||
import Icon from "@/components/Icon";
|
||||
import LinkFavicon from "@/components/LinkFavicon";
|
||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { Role } from "@/types/proto/api/v2/user_service";
|
||||
import { useUserStore, useShortcutStore } from "@/stores";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
import { Role } from "@/types/proto/api/v1/user_service";
|
||||
import { convertVisibilityFromPb } from "@/utils/visibility";
|
||||
import { showCommonDialog } from "../components/Alert";
|
||||
import AnalyticsView from "../components/AnalyticsView";
|
||||
import CreateShortcutDrawer from "../components/CreateShortcutDrawer";
|
||||
import GenerateQRCodeDialog from "../components/GenerateQRCodeDialog";
|
||||
import Icon from "../components/Icon";
|
||||
import VisibilityIcon from "../components/VisibilityIcon";
|
||||
import Dropdown from "../components/common/Dropdown";
|
||||
import { absolutifyLink, getFaviconWithGoogleS2 } from "../helpers/utils";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
|
||||
interface State {
|
||||
showEditDrawer: boolean;
|
||||
@ -42,7 +42,6 @@ const ShortcutDetail = () => {
|
||||
const creator = userStore.getUserById(shortcut.creatorId);
|
||||
const havePermission = currentUser.role === Role.ADMIN || shortcut.creatorId === currentUser.id;
|
||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@ -77,13 +76,9 @@ const ShortcutDetail = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="mx-auto max-w-8xl w-full px-4 sm:px-6 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||
<div className="mt-4 sm:mt-8 w-12 h-12 flex justify-center items-center overflow-clip">
|
||||
{favicon ? (
|
||||
<img className="w-full h-auto rounded-lg" src={favicon} decoding="async" loading="lazy" />
|
||||
) : (
|
||||
<Icon.CircleSlash className="w-full h-auto text-gray-400" strokeWidth={1} />
|
||||
)}
|
||||
<LinkFavicon url={shortcut.link} />
|
||||
</div>
|
||||
<a
|
||||
className={classNames(
|
||||
@ -95,8 +90,8 @@ const ShortcutDetail = () => {
|
||||
<div className="truncate text-3xl">
|
||||
{shortcut.title ? (
|
||||
<>
|
||||
<span>{shortcut.title}</span>
|
||||
<span className="text-gray-400">(s/{shortcut.name})</span>
|
||||
<span className="dark:text-gray-400">{shortcut.title}</span>
|
||||
<span className="text-gray-500">(s/{shortcut.name})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
@ -5,9 +5,8 @@ import { useParams } from "react-router-dom";
|
||||
import CreateShortcutDrawer from "@/components/CreateShortcutDrawer";
|
||||
import { isURL } from "@/helpers/utils";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useShortcutStore from "@/stores/v1/shortcut";
|
||||
import useUserStore from "@/stores/v1/user";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { useShortcutStore, useUserStore } from "@/stores";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
|
||||
const ShortcutSpace = () => {
|
||||
const params = useParams();
|
||||
@ -17,6 +16,7 @@ const ShortcutSpace = () => {
|
||||
const currentUser = userStore.getCurrentUser();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const [shortcut, setShortcut] = useState<Shortcut>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateShortcutDrawer, setShowCreateShortcutDrawer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -28,15 +28,21 @@ const ShortcutSpace = () => {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [shortcutName]);
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!shortcut) {
|
||||
if (!currentUser) {
|
||||
navigateTo("/404");
|
||||
return null;
|
||||
}
|
||||
console.log("currentUser", currentUser);
|
||||
|
||||
// If shortcut is not found, prompt user to create it.
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-[100svh] flex flex-col justify-center items-center p-4">
|
||||
@ -59,12 +65,14 @@ const ShortcutSpace = () => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// If shortcut is a URL, redirect to it directly.
|
||||
if (isURL(shortcut.link)) {
|
||||
window.document.title = "Redirecting...";
|
||||
window.location.href = shortcut.link;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, render the shortcut link as plain text.
|
||||
return <div>{shortcut.link}</div>;
|
||||
};
|
||||
|
@ -3,11 +3,11 @@ import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Icon from "@/components/Icon";
|
||||
import { authServiceClient } from "@/grpcweb";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useUserStore from "@/stores/v1/user";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useUserStore, useWorkspaceStore } from "@/stores";
|
||||
|
||||
const SignIn: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -64,7 +64,7 @@ const SignIn: React.FC = () => {
|
||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1 rounded-full shadow" alt="logo" />
|
||||
<Icon.CircleSlash className="w-12 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} />
|
||||
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
||||
</div>
|
||||
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
||||
|
@ -3,11 +3,11 @@ import React, { FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Icon from "@/components/Icon";
|
||||
import { authServiceClient } from "@/grpcweb";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useUserStore from "@/stores/v1/user";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useUserStore, useWorkspaceStore } from "@/stores";
|
||||
|
||||
const SignUp: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@ -75,7 +75,7 @@ const SignUp: React.FC = () => {
|
||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1 rounded-full shadow" alt="logo" />
|
||||
<Icon.CircleSlash className="w-12 h-auto dark:text-gray-500 mr-2" strokeWidth={1.5} />
|
||||
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
||||
</div>
|
||||
<p className="w-full text-2xl mt-6 dark:text-gray-500">{t("auth.create-your-account")}</p>
|
||||
|
@ -4,11 +4,10 @@ import toast from "react-hot-toast";
|
||||
import Icon from "@/components/Icon";
|
||||
import SubscriptionFAQ from "@/components/SubscriptionFAQ";
|
||||
import { subscriptionServiceClient } from "@/grpcweb";
|
||||
import { stringifyPlanType } from "@/stores/v1/subscription";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||
import { Role } from "@/types/proto/api/v2/user_service";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import { useUserStore, useWorkspaceStore } from "@/stores";
|
||||
import { stringifyPlanType } from "@/stores/subscription";
|
||||
import { PlanType } from "@/types/proto/api/v1/subscription_service";
|
||||
import { Role } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
const SubscriptionSetting: React.FC = () => {
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
@ -38,9 +37,9 @@ const SubscriptionSetting: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-8 pb-24 flex flex-col justify-start items-start gap-y-12">
|
||||
<div className="mx-auto max-w-8xl w-full px-4 sm:px-6 md:px-12 pt-8 pb-24 flex flex-col justify-start items-start gap-y-12">
|
||||
<div className="w-full">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Subscription</p>
|
||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">Subscription</p>
|
||||
<div className="mt-2">
|
||||
<span className="text-gray-500 mr-2">Current plan:</span>
|
||||
<span className="text-2xl mr-4 dark:text-gray-400">{stringifyPlanType(profile.plan)}</span>
|
||||
@ -72,7 +71,7 @@ const SubscriptionSetting: React.FC = () => {
|
||||
<div className="w-full px-6">
|
||||
<div className="max-w-4xl mx-auto mb-12">
|
||||
<Alert className="!inline-block mb-12">
|
||||
Slash is open source bookmarks and link sharing platform. Our source code is available and accessible on{" "}
|
||||
Slash is open source links shortener and sharing platform. Our source code is available and accessible on{" "}
|
||||
<Link href="https://github.com/yourselfhosted/slash" target="_blank">
|
||||
GitHub
|
||||
</Link>{" "}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import AccessTokenSection from "@/components/setting/AccessTokenSection";
|
||||
import AccountSection from "@/components/setting/AccountSection";
|
||||
import PreferenceSection from "@/components/setting/PreferenceSection";
|
||||
import AccessTokenSection from "../components/setting/AccessTokenSection";
|
||||
import AccountSection from "../components/setting/AccountSection";
|
||||
|
||||
const Setting: React.FC = () => {
|
||||
return (
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
||||
<div className="mx-auto max-w-8xl w-full px-4 sm:px-6 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
||||
<AccountSection />
|
||||
<AccessTokenSection />
|
||||
<PreferenceSection />
|
||||
|
@ -2,12 +2,11 @@ import { Alert, Button } from "@mui/joy";
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Icon from "@/components/Icon";
|
||||
import { stringifyPlanType } from "@/stores/v1/subscription";
|
||||
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||
import { Role } from "@/types/proto/api/v2/user_service";
|
||||
import MemberSection from "../components/setting/MemberSection";
|
||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||
import useUserStore from "../stores/v1/user";
|
||||
import MemberSection from "@/components/setting/MemberSection";
|
||||
import WorkspaceSection from "@/components/setting/WorkspaceSection";
|
||||
import { useUserStore, useWorkspaceStore } from "@/stores";
|
||||
import { stringifyPlanType } from "@/stores/subscription";
|
||||
import { Role } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
const WorkspaceSetting: React.FC = () => {
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
@ -26,12 +25,12 @@ const WorkspaceSetting: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
||||
<div className="mx-auto max-w-8xl w-full px-4 sm:px-6 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
||||
<Alert variant="soft" color="warning" startDecorator={<Icon.Info />}>
|
||||
You can see the settings items below because you are an Admin.
|
||||
</Alert>
|
||||
<div className="w-full flex flex-col">
|
||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Subscription</p>
|
||||
<p className="text-2xl shrink-0 font-semibold text-gray-900 dark:text-gray-500">Subscription</p>
|
||||
<div className="mt-2">
|
||||
<span className="text-gray-500 mr-2">Current plan:</span>
|
||||
<span className="text-2xl mr-4 dark:text-gray-400">{stringifyPlanType(profile.plan)}</span>
|
||||
|
@ -1,17 +1,18 @@
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import App from "@/App";
|
||||
import Root from "@/layouts/Root";
|
||||
import CollectionDashboard from "@/pages/CollectionDashboard";
|
||||
import CollectionSpace from "@/pages/CollectionSpace";
|
||||
import Home from "@/pages/Home";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import ShortcutDashboard from "@/pages/ShortcutDashboard";
|
||||
import ShortcutDetail from "@/pages/ShortcutDetail";
|
||||
import ShortcutSpace from "@/pages/ShortcutSpace";
|
||||
import SignIn from "@/pages/SignIn";
|
||||
import SignUp from "@/pages/SignUp";
|
||||
import SubscriptionSetting from "@/pages/SubscriptionSetting";
|
||||
import UserSetting from "@/pages/UserSetting";
|
||||
import WorkspaceSetting from "@/pages/WorkspaceSetting";
|
||||
import App from "../App";
|
||||
import Root from "../layouts/Root";
|
||||
import Home from "../pages/Home";
|
||||
import ShortcutDetail from "../pages/ShortcutDetail";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -34,6 +35,10 @@ const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/shortcuts",
|
||||
element: <ShortcutDashboard />,
|
||||
},
|
||||
{
|
||||
path: "/collections",
|
||||
element: <CollectionDashboard />,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { collectionServiceClient } from "@/grpcweb";
|
||||
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||
import { Collection } from "@/types/proto/api/v1/collection_service";
|
||||
|
||||
interface CollectionState {
|
||||
collectionMapById: Record<number, Collection>;
|
7
frontend/web/src/stores/index.ts
Normal file
7
frontend/web/src/stores/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import useCollectionStore from "./collection";
|
||||
import useShortcutStore from "./shortcut";
|
||||
import useUserStore from "./user";
|
||||
import useViewStore from "./view";
|
||||
import useWorkspaceStore from "./workspace";
|
||||
|
||||
export { useUserStore, useCollectionStore, useShortcutStore, useViewStore, useWorkspaceStore };
|
@ -2,7 +2,7 @@ import { isEqual } from "lodash-es";
|
||||
import { create } from "zustand";
|
||||
import { combine } from "zustand/middleware";
|
||||
import { shortcutServiceClient } from "@/grpcweb";
|
||||
import { Shortcut } from "@/types/proto/api/v2/shortcut_service";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
|
||||
|
||||
interface State {
|
||||
shortcutMapById: Record<number, Shortcut>;
|
@ -1,4 +1,4 @@
|
||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||
import { PlanType } from "@/types/proto/api/v1/subscription_service";
|
||||
|
||||
export const stringifyPlanType = (planType: PlanType) => {
|
||||
if (planType === PlanType.FREE) {
|
@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { authServiceClient, userServiceClient, userSettingServiceClient } from "@/grpcweb";
|
||||
import { User } from "@/types/proto/api/v2/user_service";
|
||||
import { UserSetting } from "@/types/proto/api/v2/user_setting_service";
|
||||
import { User } from "@/types/proto/api/v1/user_service";
|
||||
import { UserSetting } from "@/types/proto/api/v1/user_setting_service";
|
||||
|
||||
interface UserState {
|
||||
userMapById: Record<number, User>;
|
||||
@ -54,7 +54,7 @@ const useUserStore = create<UserState>()((set, get) => ({
|
||||
}
|
||||
|
||||
const { user } = await userServiceClient.getUser({
|
||||
id: Number(id),
|
||||
id: id,
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user