chore(web): update about page and access token settings
This commit is contained in:
Executable
+359
@@ -0,0 +1,359 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Black-box smoke test for a Memos release image.
|
||||||
|
#
|
||||||
|
# By default, the script builds the current worktree as a local Docker image.
|
||||||
|
# Pass --candidate-image to test an image that has already been built.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
candidate_image="${MEMOS_SMOKE_CANDIDATE_IMAGE:-}"
|
||||||
|
previous_image="${MEMOS_SMOKE_PREVIOUS_IMAGE:-}"
|
||||||
|
keep_resources="${MEMOS_SMOKE_KEEP_RESOURCES:-0}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: ./scripts/release_smoke_test.sh [options]
|
||||||
|
|
||||||
|
Runs fresh-install and previous-stable upgrade smoke tests against a Memos
|
||||||
|
Docker image. With no options, the current worktree is built and tested.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--candidate-image IMAGE Test an existing image instead of building locally.
|
||||||
|
--previous-image IMAGE Image used to seed the upgrade test. By default, the
|
||||||
|
latest stable Git tag before HEAD is used.
|
||||||
|
--keep-resources Keep containers and volumes after the test for debugging.
|
||||||
|
-h, --help Show this help text.
|
||||||
|
|
||||||
|
Environment equivalents:
|
||||||
|
MEMOS_SMOKE_CANDIDATE_IMAGE
|
||||||
|
MEMOS_SMOKE_PREVIOUS_IMAGE
|
||||||
|
MEMOS_SMOKE_KEEP_RESOURCES=1
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./scripts/release_smoke_test.sh
|
||||||
|
./scripts/release_smoke_test.sh \
|
||||||
|
--candidate-image memos-smoke:local \
|
||||||
|
--previous-image neosmemo/memos:0.29.1
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '\n==> %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'error: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--candidate-image)
|
||||||
|
(($# >= 2)) || die "--candidate-image requires a value"
|
||||||
|
candidate_image="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--previous-image)
|
||||||
|
(($# >= 2)) || die "--previous-image requires a value"
|
||||||
|
previous_image="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--keep-resources)
|
||||||
|
keep_resources=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
die "unknown option: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
for command_name in curl docker git jq; do
|
||||||
|
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
|
||||||
|
done
|
||||||
|
|
||||||
|
docker info >/dev/null 2>&1 || die "Docker is not running"
|
||||||
|
|
||||||
|
run_id="${GITHUB_RUN_ID:-local}-$(date +%s)-$$"
|
||||||
|
fresh_container="memos-smoke-fresh-$run_id"
|
||||||
|
upgrade_old_container="memos-smoke-upgrade-old-$run_id"
|
||||||
|
upgrade_new_container="memos-smoke-upgrade-new-$run_id"
|
||||||
|
fresh_volume="memos-smoke-fresh-$run_id"
|
||||||
|
upgrade_volume="memos-smoke-upgrade-$run_id"
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
frontend_index="$REPO_ROOT/server/router/frontend/dist/index.html"
|
||||||
|
frontend_index_backup="$temp_dir/frontend-index.html"
|
||||||
|
frontend_index_existed=0
|
||||||
|
frontend_index_backed_up=0
|
||||||
|
|
||||||
|
restore_frontend_index() {
|
||||||
|
if [[ "$frontend_index_backed_up" != "1" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$frontend_index_existed" == "1" ]]; then
|
||||||
|
cp "$frontend_index_backup" "$frontend_index"
|
||||||
|
else
|
||||||
|
rm -f "$frontend_index"
|
||||||
|
fi
|
||||||
|
frontend_index_backed_up=0
|
||||||
|
}
|
||||||
|
|
||||||
|
container_exists() {
|
||||||
|
docker container inspect "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
restore_frontend_index
|
||||||
|
|
||||||
|
if ((status != 0)); then
|
||||||
|
for container_name in "$fresh_container" "$upgrade_old_container" "$upgrade_new_container"; do
|
||||||
|
if container_exists "$container_name"; then
|
||||||
|
printf '\n--- docker logs: %s ---\n' "$container_name" >&2
|
||||||
|
docker logs "$container_name" >&2 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$keep_resources" == "1" ]]; then
|
||||||
|
printf '\nKept smoke-test resources for debugging:\n'
|
||||||
|
printf ' containers: %s %s %s\n' "$fresh_container" "$upgrade_old_container" "$upgrade_new_container"
|
||||||
|
printf ' volumes: %s %s\n' "$fresh_volume" "$upgrade_volume"
|
||||||
|
else
|
||||||
|
docker rm -f "$fresh_container" "$upgrade_old_container" "$upgrade_new_container" >/dev/null 2>&1 || true
|
||||||
|
docker volume rm "$fresh_volume" "$upgrade_volume" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$temp_dir"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
detect_previous_image() {
|
||||||
|
local head_sha tag tag_sha
|
||||||
|
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||||
|
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
case "$tag" in
|
||||||
|
*-rc.*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
tag_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$tag")"
|
||||||
|
if [[ "$tag_sha" == "$head_sha" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if git -C "$REPO_ROOT" merge-base --is-ancestor "$tag" HEAD; then
|
||||||
|
printf 'neosmemo/memos:%s\n' "${tag#v}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done < <(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-version:refname)
|
||||||
|
|
||||||
|
die "could not detect a previous stable release; pass --previous-image"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_local_candidate() {
|
||||||
|
local commit_sha image_tag
|
||||||
|
command -v pnpm >/dev/null 2>&1 || die "pnpm is required to build the local candidate"
|
||||||
|
|
||||||
|
commit_sha="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD)"
|
||||||
|
image_tag="memos-smoke:${commit_sha}-${run_id}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$frontend_index")"
|
||||||
|
if [[ -f "$frontend_index" ]]; then
|
||||||
|
cp "$frontend_index" "$frontend_index_backup"
|
||||||
|
frontend_index_existed=1
|
||||||
|
fi
|
||||||
|
frontend_index_backed_up=1
|
||||||
|
|
||||||
|
log "Building frontend release assets"
|
||||||
|
(
|
||||||
|
cd "$REPO_ROOT/web"
|
||||||
|
pnpm release
|
||||||
|
)
|
||||||
|
|
||||||
|
log "Building candidate image $image_tag"
|
||||||
|
docker build \
|
||||||
|
--file "$REPO_ROOT/scripts/Dockerfile" \
|
||||||
|
--build-arg VERSION=smoke-local \
|
||||||
|
--build-arg COMMIT="$commit_sha" \
|
||||||
|
--tag "$image_tag" \
|
||||||
|
"$REPO_ROOT"
|
||||||
|
|
||||||
|
restore_frontend_index
|
||||||
|
candidate_image="$image_tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_image() {
|
||||||
|
local image="$1"
|
||||||
|
if docker image inspect "$image" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "Pulling image $image"
|
||||||
|
docker pull "$image"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_until_ready() {
|
||||||
|
local container_name="$1"
|
||||||
|
local base_url="$2"
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in $(seq 1 90); do
|
||||||
|
if curl --fail --silent --show-error --max-time 2 "$base_url/healthz" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ "$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" != "true" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
docker logs "$container_name" >&2 || true
|
||||||
|
die "$container_name did not become ready"
|
||||||
|
}
|
||||||
|
|
||||||
|
current_base_url=""
|
||||||
|
|
||||||
|
set_current_base_url() {
|
||||||
|
local container_name="$1"
|
||||||
|
local port_mapping port
|
||||||
|
|
||||||
|
port_mapping="$(docker port "$container_name" 5230/tcp)"
|
||||||
|
port="${port_mapping##*:}"
|
||||||
|
[[ -n "$port" ]] || die "could not determine the host port for $container_name"
|
||||||
|
current_base_url="http://127.0.0.1:$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_container() {
|
||||||
|
local container_name="$1"
|
||||||
|
local image="$2"
|
||||||
|
local volume="$3"
|
||||||
|
|
||||||
|
docker run --detach \
|
||||||
|
--name "$container_name" \
|
||||||
|
--label "org.usememos.release-smoke=$run_id" \
|
||||||
|
--publish "127.0.0.1::5230" \
|
||||||
|
--env MEMOS_MODE=prod \
|
||||||
|
--env MEMOS_INSTANCE_URL=http://localhost \
|
||||||
|
--mount "type=volume,source=$volume,target=/var/opt/memos" \
|
||||||
|
"$image" >/dev/null
|
||||||
|
|
||||||
|
set_current_base_url "$container_name"
|
||||||
|
wait_until_ready "$container_name" "$current_base_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_frontend_assets() {
|
||||||
|
local html asset_path asset_url
|
||||||
|
html="$(curl --fail --silent --show-error "$current_base_url/")"
|
||||||
|
grep -q 'id="root"' <<<"$html" || die "frontend root element was not served"
|
||||||
|
|
||||||
|
asset_path="$(grep -o 'src="[^"]*\.js"' <<<"$html" | sed -n '1{s/^src="//;s/"$//;p;}' || true)"
|
||||||
|
[[ -n "$asset_path" ]] || die "frontend JavaScript asset was not found"
|
||||||
|
case "$asset_path" in
|
||||||
|
http://*|https://*) asset_url="$asset_path" ;;
|
||||||
|
/*) asset_url="$current_base_url$asset_path" ;;
|
||||||
|
*) asset_url="$current_base_url/$asset_path" ;;
|
||||||
|
esac
|
||||||
|
curl --fail --silent --show-error "$asset_url" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
create_admin() {
|
||||||
|
local payload response
|
||||||
|
payload="$(jq -nc '{username:"smoke-admin",password:"smoke-password",email:"smoke@example.test"}')"
|
||||||
|
response="$(curl --fail --silent --show-error \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data "$payload" \
|
||||||
|
"$current_base_url/api/v1/users")"
|
||||||
|
jq -e '.username == "smoke-admin" and .role == "ADMIN"' <<<"$response" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
sign_in() {
|
||||||
|
local payload
|
||||||
|
payload="$(jq -nc '{passwordCredentials:{username:"smoke-admin",password:"smoke-password"}}')"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data "$payload" \
|
||||||
|
"$current_base_url/api/v1/auth/signin" | jq -er '.accessToken'
|
||||||
|
}
|
||||||
|
|
||||||
|
create_memo() {
|
||||||
|
local token="$1"
|
||||||
|
local memo_id="$2"
|
||||||
|
local content="$3"
|
||||||
|
local payload response
|
||||||
|
payload="$(jq -nc --arg content "$content" '{content:$content,visibility:"PRIVATE"}')"
|
||||||
|
response="$(curl --fail --silent --show-error \
|
||||||
|
--header "Authorization: Bearer $token" \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data "$payload" \
|
||||||
|
"$current_base_url/api/v1/memos?memoId=$memo_id")"
|
||||||
|
jq -e --arg name "memos/$memo_id" --arg content "$content" '.name == $name and .content == $content' <<<"$response" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_memo() {
|
||||||
|
local token="$1"
|
||||||
|
local memo_id="$2"
|
||||||
|
local content="$3"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
--header "Authorization: Bearer $token" \
|
||||||
|
"$current_base_url/api/v1/memos/$memo_id" |
|
||||||
|
jq -e --arg content "$content" '.content == $content' >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$candidate_image" ]]; then
|
||||||
|
build_local_candidate
|
||||||
|
else
|
||||||
|
ensure_image "$candidate_image"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$previous_image" ]]; then
|
||||||
|
previous_image="$(detect_previous_image)"
|
||||||
|
fi
|
||||||
|
ensure_image "$previous_image"
|
||||||
|
[[ "$candidate_image" != "$previous_image" ]] || die "candidate and previous images must be different"
|
||||||
|
|
||||||
|
log "Candidate image: $candidate_image"
|
||||||
|
log "Previous image: $previous_image"
|
||||||
|
|
||||||
|
log "Running fresh-install smoke test"
|
||||||
|
docker volume create "$fresh_volume" >/dev/null
|
||||||
|
start_container "$fresh_container" "$candidate_image" "$fresh_volume"
|
||||||
|
assert_frontend_assets
|
||||||
|
create_admin
|
||||||
|
fresh_token="$(sign_in)"
|
||||||
|
create_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel"
|
||||||
|
|
||||||
|
docker restart "$fresh_container" >/dev/null
|
||||||
|
set_current_base_url "$fresh_container"
|
||||||
|
wait_until_ready "$fresh_container" "$current_base_url"
|
||||||
|
fresh_token="$(sign_in)"
|
||||||
|
assert_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel"
|
||||||
|
docker rm -f "$fresh_container" >/dev/null
|
||||||
|
|
||||||
|
log "Running $previous_image to $candidate_image upgrade smoke test"
|
||||||
|
docker volume create "$upgrade_volume" >/dev/null
|
||||||
|
start_container "$upgrade_old_container" "$previous_image" "$upgrade_volume"
|
||||||
|
create_admin
|
||||||
|
upgrade_token="$(sign_in)"
|
||||||
|
create_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade"
|
||||||
|
docker rm -f "$upgrade_old_container" >/dev/null
|
||||||
|
|
||||||
|
start_container "$upgrade_new_container" "$candidate_image" "$upgrade_volume"
|
||||||
|
assert_frontend_assets
|
||||||
|
upgrade_token="$(sign_in)"
|
||||||
|
assert_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade"
|
||||||
|
create_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade"
|
||||||
|
assert_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade"
|
||||||
|
|
||||||
|
log "Release smoke tests passed"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BookmarkIcon } from "lucide-react";
|
import { BookmarkIcon } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import RelativeTime from "@/components/RelativeTime";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
@@ -36,7 +37,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
|||||||
const timeValue = isArchived ? (
|
const timeValue = isArchived ? (
|
||||||
memoDisplayTime?.toLocaleString(i18n.language)
|
memoDisplayTime?.toLocaleString(i18n.language)
|
||||||
) : (
|
) : (
|
||||||
<relative-time datetime={memoDisplayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat} no-title=""></relative-time>
|
<RelativeTime date={memoDisplayTime} format={relativeTimeFormat} />
|
||||||
);
|
);
|
||||||
const displayTime = isDisplayingUpdatedTime ? (
|
const displayTime = isDisplayingUpdatedTime ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Format } from "@github/relative-time-element";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
|
interface RelativeTimeProps {
|
||||||
|
date?: Date;
|
||||||
|
format?: Format;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RelativeTime = ({ date, format }: RelativeTimeProps) => (
|
||||||
|
<relative-time datetime={date?.toISOString()} lang={i18n.language} format={format} no-title=""></relative-time>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RelativeTime;
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { CopyIcon, ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react";
|
import { ChevronRightIcon, CopyIcon, ExternalLinkIcon, KeyRoundIcon, PlusIcon, ScissorsIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import RelativeTime from "@/components/RelativeTime";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { userServiceClient } from "@/connect";
|
import { userServiceClient } from "@/connect";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { useDialog } from "@/hooks/useDialog";
|
import { useDialog } from "@/hooks/useDialog";
|
||||||
|
import { WEB_CLIPPER_URL } from "@/lib/constants";
|
||||||
import { handleError } from "@/lib/error";
|
import { handleError } from "@/lib/error";
|
||||||
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
|
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
|
||||||
import SettingGroup from "./SettingGroup";
|
|
||||||
import SettingSection from "./SettingSection";
|
import SettingSection from "./SettingSection";
|
||||||
import SettingTable from "./SettingTable";
|
|
||||||
|
const EXPIRING_SOON_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const ApiUsageExample = () => {
|
const ApiUsageExample = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
@@ -26,7 +28,7 @@ const ApiUsageExample = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full min-w-0 rounded-lg border border-border/60 bg-background">
|
<div className="relative w-full min-w-0 rounded-lg border border-border/60 bg-muted/30">
|
||||||
<pre className="overflow-x-auto p-3 pr-12 font-mono text-xs leading-5 text-foreground/85">
|
<pre className="overflow-x-auto p-3 pr-12 font-mono text-xs leading-5 text-foreground/85">
|
||||||
<code>{example}</code>
|
<code>{example}</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -37,6 +39,24 @@ const ApiUsageExample = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TokenStatus = "active" | "expiring" | "idle";
|
||||||
|
|
||||||
|
const getTokenStatus = (lastUsedAt: Date | undefined, expiresAt: Date | undefined): TokenStatus => {
|
||||||
|
if (expiresAt && expiresAt.getTime() - Date.now() < EXPIRING_SOON_MS) {
|
||||||
|
return "expiring";
|
||||||
|
}
|
||||||
|
return lastUsedAt ? "active" : "idle";
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusDot = ({ status }: { status: TokenStatus }) => (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`size-1.5 shrink-0 rounded-full ${
|
||||||
|
status === "active" ? "bg-success" : status === "expiring" ? "bg-warning" : "bg-muted-foreground/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const listAccessTokens = async (parent: string) => {
|
const listAccessTokens = async (parent: string) => {
|
||||||
const { personalAccessTokens } = await userServiceClient.listPersonalAccessTokens({ parent });
|
const { personalAccessTokens } = await userServiceClient.listPersonalAccessTokens({ parent });
|
||||||
return personalAccessTokens.sort(
|
return personalAccessTokens.sort(
|
||||||
@@ -46,6 +66,140 @@ const listAccessTokens = async (parent: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HowToUseDisclosure = () => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1.5 px-3 py-2.5 text-[13px] font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className={`size-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
|
||||||
|
{t("setting.access-token.how-to-use")}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="grid gap-4 border-t border-border/60 p-4 lg:grid-cols-2">
|
||||||
|
<div className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">{t("setting.access-token.about-description")}</p>
|
||||||
|
<ApiUsageExample />
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href="https://usememos.com/docs/security/access-tokens"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t("common.learn-more")}
|
||||||
|
<ExternalLinkIcon className="size-3" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={WEB_CLIPPER_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ScissorsIcon className="size-3" />
|
||||||
|
{t("setting.access-token.web-clipper-title")}
|
||||||
|
<ExternalLinkIcon className="size-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1.5 p-0 text-xs leading-5 text-muted-foreground">
|
||||||
|
{[
|
||||||
|
t("setting.access-token.guideline-shown-once"),
|
||||||
|
t("setting.access-token.guideline-one-per-app"),
|
||||||
|
t("setting.access-token.guideline-expiration"),
|
||||||
|
t("setting.access-token.guideline-review"),
|
||||||
|
].map((guideline) => (
|
||||||
|
<li key={guideline} className="flex gap-2">
|
||||||
|
<span className="mt-[7px] size-1 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||||
|
{guideline}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyState = ({ onCreate }: { onCreate: () => void }) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center">
|
||||||
|
<span className="mx-auto flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<KeyRoundIcon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<h4 className="mt-3 text-sm font-medium text-foreground">{t("setting.access-token.empty-title")}</h4>
|
||||||
|
<p className="mx-auto mt-1 max-w-sm text-[13px] leading-5 text-muted-foreground">{t("setting.access-token.empty-description")}</p>
|
||||||
|
<div className="mx-auto mt-4 max-w-md text-left">
|
||||||
|
<ApiUsageExample />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Button size="sm" onClick={onCreate}>
|
||||||
|
<PlusIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
{t("setting.access-token.create-first")}
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground"
|
||||||
|
href={WEB_CLIPPER_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ScissorsIcon className="size-3.5" />
|
||||||
|
{t("setting.access-token.web-clipper-title")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TokenRow = ({ token, onDelete }: { token: PersonalAccessToken; onDelete: (token: PersonalAccessToken) => void }) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const lastUsedAt = token.lastUsedAt ? timestampDate(token.lastUsedAt) : undefined;
|
||||||
|
const expiresAt = token.expiresAt ? timestampDate(token.expiresAt) : undefined;
|
||||||
|
const status = getTokenStatus(lastUsedAt, expiresAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="group flex items-center gap-3 px-4 py-3">
|
||||||
|
<StatusDot status={status} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-medium text-foreground">{token.description}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{lastUsedAt ? (
|
||||||
|
<>
|
||||||
|
{t("setting.access-token.last-used")} <RelativeTime date={lastUsedAt} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("setting.access-token.never-used")
|
||||||
|
)}
|
||||||
|
{" · "}
|
||||||
|
{expiresAt ? (
|
||||||
|
<span className={status === "expiring" ? "text-warning" : ""}>
|
||||||
|
{t("setting.access-token.expires")} <RelativeTime date={expiresAt} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("setting.access-token.no-expiration")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
className="text-muted-foreground opacity-100 transition-opacity hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
|
||||||
|
onClick={() => onDelete(token)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="w-3.5 h-auto" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AccessTokenSection = () => {
|
const AccessTokenSection = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
@@ -87,10 +241,6 @@ const AccessTokenSection = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccessToken = (token: PersonalAccessToken) => {
|
|
||||||
setDeleteTarget(token);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDeleteAccessToken = async () => {
|
const confirmDeleteAccessToken = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
const { name: tokenName, description } = deleteTarget;
|
const { name: tokenName, description } = deleteTarget;
|
||||||
@@ -111,74 +261,18 @@ const AccessTokenSection = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid w-full min-w-0 rounded-xl border border-border/60 bg-muted/20 lg:grid-cols-2">
|
{personalAccessTokens.length === 0 ? (
|
||||||
<div className="flex min-w-0 flex-col gap-2.5 p-4 sm:p-5">
|
<EmptyState onCreate={createTokenDialog.open} />
|
||||||
<h4 className="text-sm font-medium text-foreground">{t("setting.access-token.about-title")}</h4>
|
) : (
|
||||||
<p className="text-[13px] leading-6 text-muted-foreground">{t("setting.access-token.about-description")}</p>
|
<>
|
||||||
<ApiUsageExample />
|
<HowToUseDisclosure />
|
||||||
<a
|
<ul className="m-0 flex list-none flex-col divide-y divide-border/60 rounded-xl border border-border/60 p-0">
|
||||||
className="inline-flex w-fit items-center gap-1 text-[13px] leading-5 text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
|
{personalAccessTokens.map((token) => (
|
||||||
href="https://usememos.com/docs/security/access-tokens"
|
<TokenRow key={token.name} token={token} onDelete={setDeleteTarget} />
|
||||||
target="_blank"
|
))}
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{t("common.learn-more")}
|
|
||||||
<ExternalLinkIcon className="size-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-col gap-2.5 border-t border-border/60 p-4 sm:p-5 lg:border-t-0 lg:border-l">
|
|
||||||
<h4 className="text-sm font-medium text-foreground">{t("setting.access-token.guidelines-title")}</h4>
|
|
||||||
<ul className="flex list-disc flex-col gap-2 pl-4 text-[13px] leading-5 text-muted-foreground marker:text-muted-foreground/40">
|
|
||||||
<li>{t("setting.access-token.guideline-shown-once")}</li>
|
|
||||||
<li>{t("setting.access-token.guideline-one-per-app")}</li>
|
|
||||||
<li>{t("setting.access-token.guideline-expiration")}</li>
|
|
||||||
<li>{t("setting.access-token.guideline-review")}</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<SettingGroup title={t("setting.access-token.your-tokens")}>
|
|
||||||
<SettingTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
key: "description",
|
|
||||||
header: t("common.description"),
|
|
||||||
render: (_, token: PersonalAccessToken) => <span className="text-foreground">{token.description}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "createdAt",
|
|
||||||
header: t("setting.access-token.create-dialog.created-at"),
|
|
||||||
render: (_, token: PersonalAccessToken) => (token.createdAt ? timestampDate(token.createdAt) : undefined)?.toLocaleString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "expiresAt",
|
|
||||||
header: t("setting.access-token.create-dialog.expires-at"),
|
|
||||||
render: (_, token: PersonalAccessToken) =>
|
|
||||||
(token.expiresAt ? timestampDate(token.expiresAt) : undefined)?.toLocaleString() ??
|
|
||||||
t("setting.access-token.create-dialog.duration-never"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "lastUsedAt",
|
|
||||||
header: t("setting.access-token.last-used-at"),
|
|
||||||
render: (_, token: PersonalAccessToken) =>
|
|
||||||
(token.lastUsedAt ? timestampDate(token.lastUsedAt) : undefined)?.toLocaleString() ?? t("setting.access-token.never-used"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "",
|
|
||||||
className: "text-right",
|
|
||||||
render: (_, token: PersonalAccessToken) => (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteAccessToken(token)}>
|
|
||||||
<TrashIcon className="text-destructive w-4 h-auto" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={personalAccessTokens}
|
|
||||||
emptyMessage={t("setting.access-token.no-tokens-found")}
|
|
||||||
getRowKey={(token) => token.name}
|
|
||||||
/>
|
|
||||||
</SettingGroup>
|
|
||||||
|
|
||||||
{/* Create Access Token Dialog */}
|
{/* Create Access Token Dialog */}
|
||||||
<CreateAccessTokenDialog
|
<CreateAccessTokenDialog
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ export const DEFAULT_LIST_MEMOS_PAGE_SIZE = 16;
|
|||||||
// LOADING_INDICATOR_DELAY_MS is how long a load must take before the loading spinner appears.
|
// LOADING_INDICATOR_DELAY_MS is how long a load must take before the loading spinner appears.
|
||||||
// Loads that finish faster than this never render the spinner, avoiding a flash on fast/self-hosted networks.
|
// Loads that finish faster than this never render the spinner, avoiding a flash on fast/self-hosted networks.
|
||||||
export const LOADING_INDICATOR_DELAY_MS = 250;
|
export const LOADING_INDICATOR_DELAY_MS = 250;
|
||||||
|
|
||||||
|
// Official companion browser extension for saving web content to Memos.
|
||||||
|
export const WEB_CLIPPER_URL = "https://github.com/usememos/web-clipper";
|
||||||
|
|||||||
+17
-9
@@ -1,11 +1,18 @@
|
|||||||
{
|
{
|
||||||
"about": {
|
"about": {
|
||||||
"blogs": "Blogs",
|
"blogs": "Blogs",
|
||||||
|
"build": "Build",
|
||||||
|
"commit": "Commit",
|
||||||
"description": "A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.",
|
"description": "A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.",
|
||||||
|
"distribution": "Distribution",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
|
"license": "License",
|
||||||
"media": "Media",
|
"media": "Media",
|
||||||
"github-repository": "GitHub Repo",
|
"github-repository": "GitHub Repo",
|
||||||
"official-website": "Official Website"
|
"official-website": "Official Website",
|
||||||
|
"powered-by": "Powered by Memos",
|
||||||
|
"project": "Project",
|
||||||
|
"web-clipper-platforms": "Chrome + Firefox"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"admin-sign-in": "Administrator sign-in",
|
"admin-sign-in": "Administrator sign-in",
|
||||||
@@ -437,31 +444,32 @@
|
|||||||
"create-dialog": {
|
"create-dialog": {
|
||||||
"access-token-created": "Access token `{{description}}` created",
|
"access-token-created": "Access token `{{description}}` created",
|
||||||
"create-access-token": "Create Access Token",
|
"create-access-token": "Create Access Token",
|
||||||
"created-at": "Created At",
|
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"duration-1m": "1 Month",
|
"duration-1m": "1 Month",
|
||||||
"duration-90d": "90 Days",
|
"duration-90d": "90 Days",
|
||||||
"duration-8h": "8 Hours",
|
"duration-8h": "8 Hours",
|
||||||
"duration-never": "Never",
|
"duration-never": "Never",
|
||||||
"expiration": "Expiration",
|
"expiration": "Expiration",
|
||||||
"expires-at": "Expires At",
|
|
||||||
"some-description": "Some description..."
|
"some-description": "Some description..."
|
||||||
},
|
},
|
||||||
|
"create-first": "Create your first token",
|
||||||
"description": "Create and revoke the secret keys that let other apps use the Memos API as you.",
|
"description": "Create and revoke the secret keys that let other apps use the Memos API as you.",
|
||||||
"about-title": "What is a personal access token?",
|
|
||||||
"about-description": "A personal access token (PAT) is a secret key that authenticates API requests as your account. Any app or script holding one — an MCP server, a CLI, a mobile client — can do everything you can do in Memos, until the token expires or you delete it. Send it as a Bearer credential in the Authorization header:",
|
"about-description": "A personal access token (PAT) is a secret key that authenticates API requests as your account. Any app or script holding one — an MCP server, a CLI, a mobile client — can do everything you can do in Memos, until the token expires or you delete it. Send it as a Bearer credential in the Authorization header:",
|
||||||
"guidelines-title": "Keep your tokens safe",
|
"empty-description": "A token lets a script or app call the API as you. It is shown once at creation, then listed here so you can revoke it.",
|
||||||
|
"empty-title": "No access tokens yet",
|
||||||
|
"expires": "Expires",
|
||||||
"guideline-shown-once": "A token is shown only once, right after you create it (it is copied to your clipboard). Store it somewhere safe, like a password manager.",
|
"guideline-shown-once": "A token is shown only once, right after you create it (it is copied to your clipboard). Store it somewhere safe, like a password manager.",
|
||||||
"guideline-one-per-app": "Create a separate token for each app or script, so you can revoke one without breaking the others.",
|
"guideline-one-per-app": "Create a separate token for each app or script, so you can revoke one without breaking the others.",
|
||||||
"guideline-expiration": "Prefer tokens that expire. Long-lived tokens are a bigger risk if they leak.",
|
"guideline-expiration": "Prefer tokens that expire. Long-lived tokens are a bigger risk if they leak.",
|
||||||
"guideline-review": "Check the Last used column from time to time, and delete tokens you no longer recognize or need.",
|
"guideline-review": "Check the last-used time from time to time, and delete tokens you no longer recognize or need.",
|
||||||
"your-tokens": "Your tokens",
|
"how-to-use": "How to use a token",
|
||||||
"label": "Access Tokens",
|
"label": "Access Tokens",
|
||||||
"last-used-at": "Last Used",
|
"last-used": "Last used",
|
||||||
"never-used": "Never used",
|
"never-used": "Never used",
|
||||||
|
"no-expiration": "No expiration",
|
||||||
"title": "Access Tokens",
|
"title": "Access Tokens",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"no-tokens-found": "No access tokens found"
|
"web-clipper-title": "Use a token with Memos Web Clipper"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"change-password": "Change password",
|
"change-password": "Change password",
|
||||||
|
|||||||
+53
-25
@@ -1,6 +1,7 @@
|
|||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon, ScissorsIcon } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
|
import { WEB_CLIPPER_URL } from "@/lib/constants";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
|
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
|
||||||
@@ -25,6 +26,10 @@ const Chip = ({ href, children }: { href?: string; children: React.ReactNode })
|
|||||||
return <span className={className}>{children}</span>;
|
return <span className={className}>{children}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<h2 className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">{children}</h2>
|
||||||
|
);
|
||||||
|
|
||||||
const About = () => {
|
const About = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const { profile, generalSetting } = useInstance();
|
const { profile, generalSetting } = useInstance();
|
||||||
@@ -36,7 +41,7 @@ const About = () => {
|
|||||||
const instanceLogo = customProfile?.logoUrl || DEFAULT_LOGO;
|
const instanceLogo = customProfile?.logoUrl || DEFAULT_LOGO;
|
||||||
const isCustomBranded = instanceTitle !== DEFAULT_TITLE;
|
const isCustomBranded = instanceTitle !== DEFAULT_TITLE;
|
||||||
|
|
||||||
// Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit chip.
|
// Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit row.
|
||||||
const hasSemver = isSemver(profile.version);
|
const hasSemver = isSemver(profile.version);
|
||||||
const releaseUrl = hasSemver ? `${GITHUB_RELEASE_URL_PREFIX}${profile.version}` : "";
|
const releaseUrl = hasSemver ? `${GITHUB_RELEASE_URL_PREFIX}${profile.version}` : "";
|
||||||
const versionLabel = hasSemver ? `v${profile.version}` : profile.version;
|
const versionLabel = hasSemver ? `v${profile.version}` : profile.version;
|
||||||
@@ -44,47 +49,70 @@ const About = () => {
|
|||||||
const commitUrl = hasCommitSha ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
|
const commitUrl = hasCommitSha ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
|
||||||
const shortCommit = hasCommitSha ? profile.commit.slice(0, 7) : "";
|
const shortCommit = hasCommitSha ? profile.commit.slice(0, 7) : "";
|
||||||
|
|
||||||
|
const buildRows: { label: string; value: React.ReactNode }[] = [];
|
||||||
|
if (profile.version) {
|
||||||
|
buildRows.push({ label: t("common.version"), value: <Chip href={releaseUrl || undefined}>{versionLabel}</Chip> });
|
||||||
|
}
|
||||||
|
if (shortCommit) {
|
||||||
|
buildRows.push({ label: t("about.commit"), value: <Chip href={commitUrl}>{shortCommit}</Chip> });
|
||||||
|
}
|
||||||
|
buildRows.push({ label: t("about.license"), value: <Chip href="https://github.com/usememos/memos/blob/main/LICENSE">MIT</Chip> });
|
||||||
|
if (isCustomBranded) {
|
||||||
|
buildRows.push({
|
||||||
|
label: t("about.distribution"),
|
||||||
|
value: <span className="text-[13px] text-muted-foreground">{t("about.powered-by")}</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const projectLinks = [
|
const projectLinks = [
|
||||||
{ label: t("about.official-website"), note: "the project homepage", href: "https://usememos.com/" },
|
{ label: t("about.official-website"), note: "the project homepage", href: "https://usememos.com/" },
|
||||||
{ label: t("about.documents"), note: "deploy, configure, use", href: "https://usememos.com/docs" },
|
{ label: t("about.documents"), note: "deploy, configure, use", href: "https://usememos.com/docs" },
|
||||||
{ label: "API Docs", note: "REST + gRPC reference", href: "https://usememos.com/docs/api" },
|
{ label: "API Docs", note: "REST + gRPC reference", href: "https://usememos.com/docs/api" },
|
||||||
{ label: t("about.github-repository"), note: "source, issues, releases", href: "https://github.com/usememos/memos" },
|
{ label: t("about.github-repository"), note: "source, issues, releases", href: "https://github.com/usememos/memos" },
|
||||||
|
{ label: "Web Clipper", note: t("about.web-clipper-platforms"), href: WEB_CLIPPER_URL, icon: ScissorsIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
|
<section className="mx-auto w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
|
||||||
<div className="w-full">
|
<div className="mx-auto w-full max-w-2xl px-1 py-6 sm:py-8">
|
||||||
<div className="w-full rounded-xl border border-border bg-background px-4 py-4 text-muted-foreground">
|
<header>
|
||||||
<div className="flex min-w-0 items-center gap-4">
|
<img className="size-10 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
|
||||||
<img className="size-16 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<div className="min-w-0">
|
<h1 className="text-lg font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{profile.demo && <Badge variant="warning">Demo</Badge>}
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
|
|
||||||
{profile.demo && <Badge variant="warning">Demo</Badge>}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{instanceTagline}</p>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
||||||
{profile.version && <Chip href={releaseUrl || undefined}>{versionLabel}</Chip>}
|
|
||||||
{shortCommit && <Chip href={commitUrl}>{shortCommit}</Chip>}
|
|
||||||
{isCustomBranded && <Chip>Powered by Memos</Chip>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 max-w-md text-[26px] font-light leading-snug tracking-[-0.015em] text-foreground">{instanceTagline}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<nav aria-label="Project links" className="mt-5">
|
<section className="mt-9">
|
||||||
|
<SectionLabel>{t("about.build")}</SectionLabel>
|
||||||
|
<dl className="mt-2.5 border-t border-border">
|
||||||
|
{buildRows.map((row) => (
|
||||||
|
<div key={row.label} className="grid grid-cols-[110px_1fr] items-center border-b border-border/60 py-2">
|
||||||
|
<dt className="text-[13px] text-muted-foreground">{row.label}</dt>
|
||||||
|
<dd className="m-0 flex min-w-0 items-center">{row.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-9">
|
||||||
|
<SectionLabel>{t("about.project")}</SectionLabel>
|
||||||
|
<nav aria-label="Project links" className="mt-2.5 border-t border-border">
|
||||||
{projectLinks.map((link) => (
|
{projectLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
className="group flex flex-col gap-0.5 border-t border-border py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"
|
className="group flex items-baseline justify-between gap-4 border-b border-border/60 py-2.5"
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="flex min-w-0 items-baseline gap-2">
|
||||||
<span className="text-sm font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
|
{link.icon && <link.icon className="size-3.5 shrink-0 translate-y-0.5 text-muted-foreground" />}
|
||||||
|
<span className="text-[13px] font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
|
||||||
{link.label}
|
{link.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground">{link.note}</span>
|
<span className="hidden truncate text-xs text-muted-foreground sm:inline">{link.note}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground group-hover:text-foreground">
|
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground group-hover:text-foreground">
|
||||||
{link.href.replace("https://", "")}
|
{link.href.replace("https://", "")}
|
||||||
@@ -93,9 +121,9 @@ const About = () => {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="border-t border-border pt-3 text-xs text-muted-foreground">Free and open source under the MIT license.</p>
|
<p className="mt-8 text-xs text-muted-foreground">Free and open source under the MIT license.</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user