fix(desktop): improve self-test coverage and ui polish

This commit is contained in:
DIYgod 2026-03-13 10:19:42 +08:00
parent df83cf62ea
commit aea75fe57b
31 changed files with 800 additions and 196 deletions

View File

@ -0,0 +1,178 @@
import { mkdir } from "node:fs/promises"
import { chromium } from "@playwright/test"
import { join } from "pathe"
import { createTestAccount, tryDeleteCurrentUser } from "../support/account"
import {
closeSettings,
dismissFeedForm,
followOnboardingFeed,
openSettings,
openWebApp,
} from "../support/app"
import { bootstrapAuthenticatedWebSession } from "../support/auth-bootstrap"
import { buildWebAppURL, resolveDesktopE2EEnv } from "../support/env"
const SETTING_TABS = [
"general",
"appearance",
"notifications",
"shortcuts",
"ai",
"integration",
"feeds",
"list",
"profile",
"data-control",
"cli",
"plan",
"about",
] as const
const SUBVIEW_ROUTES = ["discover", "power", "action", "rsshub", "ai"] as const
const waitForUiSettled = async (page: import("@playwright/test").Page, delay = 1200) => {
await page.waitForLoadState("domcontentloaded")
await page.waitForTimeout(delay)
}
const waitForRouteReady = async (
page: import("@playwright/test").Page,
route: (typeof SUBVIEW_ROUTES)[number],
) => {
await waitForUiSettled(page, route === "power" ? 3500 : 1200)
if (route === "power") {
await page
.waitForFunction(
() =>
document.body.textContent?.includes("Your Balance") ||
document.body.textContent?.includes("Transactions") ||
document.body.textContent?.includes("Create Wallet"),
undefined,
{ timeout: 15_000 },
)
.catch(() => {})
}
}
async function main() {
const env = resolveDesktopE2EEnv()
const outputDir = join(
env.desktopAppDir,
"e2e",
"artifacts",
"ui-audit",
`run-${new Date().toISOString().replaceAll(":", "-")}`,
)
await mkdir(outputDir, { recursive: true })
const browser = await chromium.launch({
channel: "chromium",
headless: true,
args: ["--disable-web-security"],
})
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: {
width: 1440,
height: 980,
},
colorScheme: "light",
})
let page = await context.newPage()
const account = createTestAccount("ui-audit")
let screenshotIndex = 1
const capture = async (name: string) => {
const path = join(outputDir, `${String(screenshotIndex).padStart(2, "0")}-${name}.png`)
screenshotIndex += 1
await page.screenshot({ path, fullPage: false })
console.info(path)
}
const bootstrapAccount = async () => {
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await bootstrapAuthenticatedWebSession(page, env, account)
return
} catch (error) {
await capture(`auth-bootstrap-attempt-${attempt}-failed`)
if (attempt === 3) {
throw error
}
await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" })
await waitForUiSettled(page)
}
}
}
try {
await openWebApp(page, env)
await waitForUiSettled(page)
await capture("00-login-modal")
await page.close()
page = await context.newPage()
await bootstrapAccount()
await waitForUiSettled(page)
await capture("01-home-articles")
await followOnboardingFeed(page, env)
await waitForUiSettled(page)
await capture("02-discover-follow")
await dismissFeedForm(page)
const timelineTabs = await page.locator('[data-testid^="timeline-tab-"]').all()
for (const tab of timelineTabs) {
const testId = await tab.getAttribute("data-testid")
if (!testId) continue
await tab.click()
await waitForUiSettled(page)
await capture(`timeline-${testId.replace("timeline-tab-", "")}`)
}
for (const route of SUBVIEW_ROUTES) {
await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" })
await waitForRouteReady(page, route)
await capture(`subview-${route}`)
}
await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" })
await waitForUiSettled(page)
await openSettings(page)
await waitForUiSettled(page)
for (const tab of SETTING_TABS) {
if (tab === "general") {
await capture("settings-general")
continue
}
const tabTrigger = page.getByTestId(`settings-tab-${tab}`)
if (!(await tabTrigger.isVisible().catch(() => false))) {
continue
}
await tabTrigger.click()
await waitForUiSettled(page)
await capture(`settings-${tab}`)
}
await closeSettings(page)
await waitForUiSettled(page)
await capture("home-after-settings")
} finally {
await tryDeleteCurrentUser(page, env).catch(() => null)
await context.close().catch(() => {})
await browser.close().catch(() => {})
}
}
void main()

View File

@ -549,10 +549,14 @@ export const expectOnboardingFeedUnsubscribed = async (
export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => {
await returnToMainShell(page)
await page.getByTestId("timeline-tab-videos").click()
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBe(0)
const videosTab = page.getByTestId("timeline-tab-videos")
await videosTab.click()
await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 })
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0)
await page.getByTestId("timeline-tab-articles").click()
const articlesTab = page.getByTestId("timeline-tab-articles")
await articlesTab.click()
await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 })
await expect.poll(async () => page.locator("[data-entry-id]").count()).toBeGreaterThan(0)
const unreadOnboardingEntry = page

View File

@ -0,0 +1,224 @@
import type { BrowserContext, Page } from "@playwright/test"
import { nanoid } from "nanoid"
import type { TestAccount } from "./account"
import { injectRecaptchaToken, waitForAuthenticated } from "./app"
import type { DesktopE2EEnv } from "./env"
import { buildWebAppURL } from "./env"
type AuthBootstrapResponse = {
token?: string | null
error?: {
message?: string
} | null
}
type ParsedCookie = {
expires?: number
httpOnly: boolean
name: string
path: string
sameSite: "Lax" | "None" | "Strict"
secure: boolean
value: string
}
const splitSetCookieHeader = (header: string) => {
const parts: string[] = []
let buffer = ""
for (const char of header) {
if (char === ",") {
const recent = buffer.toLowerCase()
const hasExpires = recent.includes("expires=")
const hasGmt = /gmt/i.test(recent)
if (hasExpires && !hasGmt) {
buffer += char
continue
}
if (buffer.trim()) {
parts.push(buffer.trim())
}
buffer = ""
continue
}
buffer += char
}
if (buffer.trim()) {
parts.push(buffer.trim())
}
return parts
}
const parseSetCookieHeader = (header: string): ParsedCookie[] => {
return splitSetCookieHeader(header)
.map((cookie) => {
const [nameValue, ...attributes] = cookie.split(";").map((part) => part.trim())
const [name, ...valueParts] = nameValue?.split("=") ?? []
if (!name) {
return null
}
const parsedCookie: ParsedCookie = {
name,
value: valueParts.join("="),
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
}
for (const attribute of attributes) {
const [rawKey, ...rawValueParts] = attribute.split("=")
const key = rawKey?.toLowerCase()
const value = rawValueParts.join("=")
switch (key) {
case "expires": {
const expires = new Date(value)
if (!Number.isNaN(expires.getTime())) {
parsedCookie.expires = expires.getTime() / 1000
}
break
}
case "httponly": {
parsedCookie.httpOnly = true
break
}
case "path": {
parsedCookie.path = value || "/"
break
}
case "samesite": {
if (value === "None" || value === "Strict" || value === "Lax") {
parsedCookie.sameSite = value
}
break
}
case "secure": {
parsedCookie.secure = true
break
}
}
}
return parsedCookie
})
.filter(Boolean)
}
const requestAuth = async ({
apiURL,
path,
body,
}: {
apiURL: string
body: Record<string, unknown>
path: string
}) => {
const response = await fetch(new URL(path, apiURL), {
method: "POST",
headers: {
"Cache-Control": "no-store",
"content-type": "application/json",
"x-app-name": "Folo Web",
"x-app-platform": "desktop/web",
"x-app-version": "1.4.0",
"x-client-id": nanoid(),
"x-session-id": nanoid(),
"x-token": "ac:fallback",
},
body: JSON.stringify(body),
})
return {
response,
body: (await response.json().catch(() => null)) as AuthBootstrapResponse | null,
setCookie: response.headers.get("set-cookie"),
}
}
const signIn = (env: DesktopE2EEnv, account: TestAccount) =>
requestAuth({
apiURL: env.apiURL,
path: "/better-auth/sign-in/email",
body: {
email: account.email,
password: account.password,
rememberMe: true,
},
})
const signUp = (env: DesktopE2EEnv, account: TestAccount) =>
requestAuth({
apiURL: env.apiURL,
path: "/better-auth/sign-up/email",
body: {
email: account.email,
password: account.password,
name: account.email.split("@")[0] ?? account.email,
callbackURL: `${env.webURL}/login`,
},
})
const applyCookiesToContext = async (
context: BrowserContext,
env: DesktopE2EEnv,
setCookieHeader: string,
) => {
const cookies = parseSetCookieHeader(setCookieHeader)
await context.addCookies(
cookies.map((cookie) => ({
url: env.apiURL,
name: cookie.name,
value: cookie.value,
httpOnly: cookie.httpOnly,
secure: cookie.secure,
sameSite: cookie.sameSite,
expires: cookie.expires,
})),
)
}
export const bootstrapAuthenticatedWebSession = async (
page: Page,
env: DesktopE2EEnv,
account: TestAccount,
) => {
let signInResult = await signIn(env, account)
if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) {
const signUpResult = await signUp(env, account)
const signUpError = signUpResult.body?.error?.message?.toLowerCase() ?? ""
const isExistingAccount =
signUpError.includes("exist") ||
signUpError.includes("already") ||
signUpError.includes("taken")
if ((!signUpResult.response.ok || signUpResult.body?.error) && !isExistingAccount) {
throw new Error(
signUpResult.body?.error?.message ||
signInResult.body?.error?.message ||
`auth bootstrap failed with ${signUpResult.response.status}`,
)
}
signInResult = await signIn(env, account)
}
if (!signInResult.response.ok || signInResult.body?.error || !signInResult.setCookie) {
throw new Error(
signInResult.body?.error?.message || `sign in failed with ${signInResult.response.status}`,
)
}
await applyCookiesToContext(page.context(), env, signInResult.setCookie)
await injectRecaptchaToken(page, env)
await page.goto(buildWebAppURL(env, "/"), { waitUntil: "domcontentloaded" })
await waitForAuthenticated(page)
}

View File

@ -24,18 +24,26 @@ export class AuthService extends IpcService {
const url = new URL(apiURL)
const isSecure = url.protocol === "https:"
const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1"
const cookieNames = [
BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,
...(isSecure && !isLocalhost ? ["__Secure-better-auth.session_token"] : []),
]
await mainWindow.webContents.session.cookies.set({
url: apiURL,
name: BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN,
value: token,
...(isLocalhost ? {} : { domain: url.hostname }),
path: "/",
httpOnly: true,
secure: isSecure,
sameSite: "no_restriction",
expirationDate: new Date().setDate(new Date().getDate() + 30),
})
await Promise.all(
cookieNames.map((name) =>
mainWindow.webContents.session.cookies.set({
url: apiURL,
name,
value: token,
...(isLocalhost ? {} : { domain: url.hostname }),
path: "/",
httpOnly: true,
secure: isSecure,
sameSite: "no_restriction",
expirationDate: new Date().setDate(new Date().getDate() + 30),
}),
),
)
}
private async clearSessionToken(): Promise<void> {
@ -78,7 +86,7 @@ export class AuthService extends IpcService {
const token = typeof data.token === "string" ? data.token : null
const persistedSessionToken = sessionToken ?? token
if (response.ok && persistedSessionToken) {
void this.applySessionToken(persistedSessionToken).catch(() => {})
await this.applySessionToken(persistedSessionToken)
}
if (sessionToken) {

View File

@ -12,8 +12,8 @@ import { actionActions } from "@follow/store/action/store"
import { nextFrame } from "@follow/utils"
import { JsonObfuscatedCodec } from "@follow/utils/json-codec"
import { cn } from "@follow/utils/utils"
import { repository } from "@pkg"
import { useQueryClient } from "@tanstack/react-query"
import { m } from "motion/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useBlocker } from "react-router"
@ -44,15 +44,14 @@ import {
import { useSetSubViewRightView } from "../app-layout/subview/hooks"
import { generateExportFilename } from "./utils"
const EmptyActionPlaceholder = () => {
const { t } = useTranslation("settings")
const EmptyActionPlaceholder = ({ onCreateRule }: { onCreateRule: () => void }) => {
const { t } = useTranslation(["settings", "common"])
return (
<div className="relative flex min-h-96 w-full items-center justify-center">
<div className="flex flex-col items-center gap-6 text-center">
{/* Simple icon */}
<div className="flex size-14 items-center justify-center rounded-lg border border-fill-secondary bg-fill-quinary">
<i className="i-mgc-magic-2-cute-re size-7 text-text-secondary" />
<div className="flex min-h-96 w-full items-center justify-center py-10">
<div className="flex w-full max-w-xl flex-col items-center gap-6 rounded-3xl border border-fill-secondary bg-material-ultra-thin px-8 py-10 text-center shadow-sm">
<div className="flex size-16 items-center justify-center rounded-2xl border border-fill-secondary bg-fill-quinary">
<i className="i-mgc-magic-2-cute-re size-8 text-text-secondary" />
</div>
<div className="space-y-2">
@ -63,25 +62,23 @@ const EmptyActionPlaceholder = () => {
{t("actions.action_card.empty.description")}
</p>
</div>
</div>
<m.div
className="fixed right-20 top-12 z-[1000]"
animate={{
x: [0, 8, 0],
y: [0, -4, 0],
opacity: [0.5, 1, 0.5],
}}
transition={{
duration: 2.5,
repeat: Infinity,
ease: "easeInOut",
}}
>
<div className="flex items-center gap-2 text-text-secondary">
<span className="text-sm font-medium">{t("actions.action_card.empty.start")}</span>
<i className="i-mgc-arrow-right-up-cute-re size-5" />
<div className="flex flex-wrap items-center justify-center gap-3">
<Button onClick={onCreateRule}>
<i className="i-mgc-add-cute-re mr-2 size-4" />
{t("actions.action_card.empty.cta")}
</Button>
<a
href={`${repository.url}/wiki/Actions`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-fill-secondary hover:text-text"
>
<i className="i-mgc-book-6-cute-re size-4" />
<span>{t("words.documentation", { ns: "common" })}</span>
</a>
</div>
</m.div>
</div>
</div>
)
}
@ -164,7 +161,7 @@ export const ActionSetting = () => {
</div>
</div>
) : (
<EmptyActionPlaceholder />
<EmptyActionPlaceholder onCreateRule={handleCreateRule} />
)}
</>
)

View File

@ -14,7 +14,7 @@ import { IN_ELECTRON } from "@follow/shared/constants"
import { env } from "@follow/shared/env.desktop"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
@ -217,7 +217,16 @@ export function LoginWithPassword({
<FormItem>
<FormLabel>{t("login.email")}</FormLabel>
<FormControl>
<Input data-testid="login-email-input" type="email" {...field} />
<Input
data-testid="login-email-input"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -245,7 +254,15 @@ export function LoginWithPassword({
</a>
</FormLabel>
<FormControl>
<Input data-testid="login-password-input" type="password" {...field} />
<Input
data-testid="login-password-input"
type="password"
autoCapitalize="none"
autoComplete={IN_ELECTRON ? "current-password" : "new-password"}
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -268,17 +285,21 @@ export function LoginWithPassword({
<Divider className="my-4" />
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you don't have an account,{" "}
<button
data-testid="login-switch-register"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("register")}
>
Sign up
<i className="i-mgc-right-cute-fi !text-text" />
</button>
<div className="pb-2 text-center text-sm text-text-secondary">
<Trans
t={t}
i18nKey="login.no_account"
components={{
strong: (
<button
data-testid="login-switch-register"
type="button"
className="inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
onClick={() => onLoginStateChange("register")}
/>
),
}}
/>
</div>
</Form>
)
@ -396,7 +417,16 @@ export function RegisterForm({
<FormItem>
<FormLabel>{t("register.email")}</FormLabel>
<FormControl>
<Input data-testid="register-email-input" type="email" {...field} />
<Input
data-testid="register-email-input"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
inputMode="email"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -413,7 +443,15 @@ export function RegisterForm({
: `${t("register.password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input data-testid="register-password-input" type="password" {...field} />
<Input
data-testid="register-password-input"
type="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -430,7 +468,15 @@ export function RegisterForm({
: `${t("register.confirm_password")} (${t("register.password_optional")})`}
</FormLabel>
<FormControl>
<Input data-testid="register-confirm-password-input" type="password" {...field} />
<Input
data-testid="register-confirm-password-input"
type="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -452,17 +498,21 @@ export function RegisterForm({
</Form>
<Divider className="my-4" />
<div className="flex items-center justify-center gap-1 pb-2 text-center text-sm">
If you already have an account,{" "}
<button
data-testid="register-switch-login"
type="button"
className="flex cursor-pointer items-center gap-1 text-accent hover:underline"
onClick={() => onLoginStateChange("login")}
>
Sign in
<i className="i-mgc-right-cute-fi !text-text" />
</button>
<div className="pb-2 text-center text-sm text-text-secondary">
<Trans
t={t}
i18nKey="login.have_account"
components={{
strong: (
<button
data-testid="register-switch-login"
type="button"
className="inline-flex cursor-pointer items-center gap-1 text-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
onClick={() => onLoginStateChange("login")}
/>
),
}}
/>
</div>
</div>
)

View File

@ -31,7 +31,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
const { canClose = true, runtime } = props
const { t } = useTranslation()
const { t } = useTranslation(["app", "common"])
const { data: authProviders, isLoading } = useAuthProviders()
const { status } = useSession()
@ -159,10 +159,11 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{!IN_ELECTRON && (
<button
type="button"
className="absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent hover:bg-fill/20"
aria-label={t("words.close", { ns: "common" })}
className="absolute -right-2 -top-2 flex size-8 items-center justify-center rounded-lg border-0 bg-transparent transition-colors hover:bg-fill/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
onClick={modal.dismiss}
>
<i className="i-mgc-close-cute-re size-4" />
<i aria-hidden className="i-mgc-close-cute-re pointer-events-none size-4" />
</button>
)}
{isEmail ? (
@ -181,13 +182,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<div className="flex flex-col gap-4">
{/* Login Providers */}
<div className="flex flex-col gap-2.5">
{visibleProviders.map(([key, provider], index) => (
<m.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
>
{visibleProviders.map(([key, provider]) => (
<div key={key}>
<button
data-testid={`login-provider-${key}`}
type="button"
@ -198,28 +194,32 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
loginHandler(key, "app")
}
}}
className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-all duration-200 hover:border-folo/30 hover:bg-folo/10"
className="group center relative w-full gap-2 rounded-xl border border-border bg-material-medium py-3.5 pl-5 font-medium backdrop-blur-sm transition-colors duration-200 hover:border-folo/30 hover:bg-folo/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
{provider.icon64 ? (
<img
className={cn(
"absolute left-7 size-5 object-contain",
"pointer-events-none absolute left-7 size-5 object-contain",
!provider.iconDark64 &&
"dark:brightness-[0.85] dark:hue-rotate-180 dark:invert",
)}
src={isDark ? provider.iconDark64 || provider.icon64 : provider.icon64}
alt={provider.name}
alt=""
aria-hidden="true"
/>
) : (
<i className="i-mgc-mail-cute-re absolute left-7 size-5 text-text-secondary" />
<i
aria-hidden
className="i-mgc-mail-cute-re pointer-events-none absolute left-7 size-5 text-text-secondary"
/>
)}
<span className="relative z-10">
<span className="pointer-events-none relative z-10">
{t("login.continueWith", { provider: provider.name })}
</span>
{lastMethod === key && (
<m.div
className="absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white"
className="pointer-events-none absolute -right-2 -top-2 z-20 rounded-lg bg-accent px-2.5 py-1 text-xs font-medium text-white"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={Spring.presets.bouncy}
@ -228,7 +228,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
</m.div>
)}
</button>
</m.div>
</div>
))}
</div>
@ -238,9 +238,9 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<button
type="button"
onClick={() => handleOpenToken()}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-fill-secondary hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
<i className="i-mgc-key-2-cute-re size-3.5" />
<i aria-hidden className="i-mgc-key-2-cute-re size-3.5" />
<span>{t("login.enter_token")}</span>
</button>
</div>
@ -249,7 +249,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<button
type="button"
onClick={() => handleOpenLegal("tos")}
className="text-accent transition-colors hover:text-accent/80 hover:underline"
className="text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
{t("login.terms")}
</button>
@ -257,7 +257,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
<button
type="button"
onClick={() => handleOpenLegal("privacy")}
className="text-accent transition-colors hover:text-accent/80 hover:underline"
className="text-accent transition-colors hover:text-accent/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2"
>
{t("login.privacy")}
</button>

View File

@ -69,6 +69,9 @@ export const TokenModalContent = () => {
autoFocus
className="mt-1 dark:text-zinc-200"
placeholder="folo://auth?token=xxx"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...field}
/>
</FormControl>

View File

@ -42,9 +42,8 @@ export function DiscoveryContent() {
}
return (
<div className="relative mx-auto w-full max-w-[800px] space-y-6">
{/* Segment Toggle - Centered */}
<div className="relative flex justify-center">
<div className="relative mx-auto w-full max-w-[880px] space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<SegmentGroup
value={activeView}
onValueChanged={(value) => setActiveView(value as DiscoveryView)}
@ -70,28 +69,23 @@ export function DiscoveryContent() {
/>
</SegmentGroup>
{/* Filters Bar - Inside Content Area */}
<div className="absolute right-0 flex items-center justify-end gap-4">
<div className="flex items-center gap-2">
<span className="shrink-0 text-sm font-medium text-text-secondary">
{t("words.language")}:
</span>
<ResponsiveSelect
value={lang}
onValueChange={handleLangChange}
triggerClassName="h-8 rounded border-0"
size="sm"
items={LanguageOptions}
renderItem={(item) => tCommon(item.label as any)}
renderValue={(item) => tCommon(item.label as any)}
/>
</div>
<div className="flex items-center gap-2">
<span className="shrink-0 text-sm font-medium text-text-secondary">
{t("words.language")}:
</span>
<ResponsiveSelect
value={lang}
onValueChange={handleLangChange}
triggerClassName="h-8 rounded border-0 bg-material-ultra-thin"
size="sm"
items={LanguageOptions}
renderItem={(item) => tCommon(item.label as any)}
renderValue={(item) => tCommon(item.label as any)}
/>
</div>
</div>
{/* Content Area with Filters */}
<div className="min-h-[400px]">
{/* Content */}
<div className="min-h-[400px] rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm">
{activeView === "trending" ? (
<Trending center limit={20} hideHeader />
) : (

View File

@ -301,7 +301,7 @@ export function UnifiedDiscoverForm() {
className="w-full max-w-2xl"
data-testid="discover-form"
>
<div className="p-6">
<div className="rounded-2xl border border-fill-secondary bg-background/70 p-4 shadow-sm">
<FormField
control={form.control}
name="keyword"

View File

@ -8,21 +8,33 @@ export const CreateWallet = () => {
const { t } = useTranslation("settings")
return (
<div>
<p className="text-base">
<Trans
i18nKey="wallet.create.description"
ns="settings"
components={{
PowerIcon: <i className="i-mgc-power translate-y-[2px] text-folo" />,
strong: <strong />,
}}
/>
</p>
<div className="mt-4 text-right">
<Button variant="primary" isLoading={mutation.isPending} onClick={() => mutation.mutate()}>
{t("wallet.create.button")}
</Button>
<div className="rounded-2xl border border-fill-secondary bg-material-ultra-thin p-6 shadow-sm">
<div className="flex flex-col items-start gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-3">
<div className="flex size-12 items-center justify-center rounded-2xl bg-fill-quaternary text-folo">
<i className="i-mgc-power text-2xl" />
</div>
<p className="max-w-2xl text-base text-text-secondary">
<Trans
i18nKey="wallet.create.description"
ns="settings"
components={{
PowerIcon: <i className="i-mgc-power translate-y-[2px] text-folo" />,
strong: <strong className="text-text" />,
}}
/>
</p>
</div>
<div className="shrink-0">
<Button
variant="primary"
isLoading={mutation.isPending}
onClick={() => mutation.mutate()}
>
{t("wallet.create.button")}
</Button>
</div>
</div>
</div>
)

View File

@ -36,9 +36,14 @@ export const MyWalletSection = ({ className }: { className?: string }) => {
return <CreateWallet />
}
return (
<div className={cn(className)}>
<div
className={cn(
"rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm",
className,
)}
>
<SettingSectionTitle title={t("wallet.balance.title")} margin="compact" />
<div className="mb-2 flex items-center justify-between">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-1">
<Balance className="text-xl font-bold text-folo">
@ -46,7 +51,7 @@ export const MyWalletSection = ({ className }: { className?: string }) => {
</Balance>
</div>
<Tooltip>
<TooltipTrigger className="mt-1 block">
<TooltipTrigger className="mt-2 block">
<div className="flex flex-row items-center gap-x-2 text-xs">
<span className="flex items-center gap-1 text-left">
{t("wallet.balance.withdrawable")} <i className="i-mgc-question-cute-re" />

View File

@ -1,6 +1,7 @@
import { LoadingCircle } from "@follow/components/ui/loading/index.js"
import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx"
import { useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import { TransactionTypes } from "@follow-app/client-sdk"
import { useState } from "react"
import { useTranslation } from "react-i18next"
@ -28,8 +29,15 @@ export const TransactionsSection: Component = ({ className }) => {
if (!myWallet) return null
const hasTransactions = Boolean(transactions.data?.length)
return (
<div className="relative flex min-w-0 grow flex-col">
<div
className={cn(
"relative flex min-w-0 grow flex-col rounded-2xl border border-fill-secondary bg-material-ultra-thin p-5 shadow-sm",
className,
)}
>
<SettingSectionTitle title={t("wallet.transactions.title")} />
<Tabs value={type} onValueChange={(val) => setType(val)}>
<TabsList className="relative border-b-transparent">
@ -40,8 +48,8 @@ export const TransactionsSection: Component = ({ className }) => {
))}
</TabsList>
</Tabs>
<TxTable type={type} className={className} />
{!!transactions.data?.length && (
{hasTransactions ? <TxTable type={type} /> : null}
{hasTransactions && (
<a
className="my-2 w-full text-sm text-zinc-400 underline"
href={`${getBlockchainExplorerUrl()}/address/${myWallet.address}`}
@ -51,12 +59,20 @@ export const TransactionsSection: Component = ({ className }) => {
</a>
)}
{(transactions.isFetching || !transactions.data?.length) && (
<div className="my-2 flex w-full justify-center text-sm text-zinc-400">
{(transactions.isFetching || !hasTransactions) && (
<div className="my-4 flex w-full justify-center text-sm text-zinc-400">
{transactions.isFetching ? (
<LoadingCircle size="medium" />
) : (
t("wallet.transactions.noTransactions")
<div className="flex min-h-56 w-full flex-col items-center justify-center rounded-xl border border-dashed border-border bg-background/60 px-6 text-center">
<i className="i-mgc-power mb-3 text-4xl text-text-quaternary" />
<p className="text-sm font-medium text-text">
{t("wallet.transactions.empty.title")}
</p>
<p className="mt-1 max-w-sm text-sm text-text-secondary">
{t("wallet.transactions.empty.description")}
</p>
</div>
)}
</div>
)}

View File

@ -195,22 +195,24 @@ const Content: FC<{
</SettingSectionHighlightIdContext>
<div className="h-16" />
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">
<Trans
ns="settings"
i18nKey="common.give_star"
components={{
Link: (
<a
href={`${repository.url}`}
className="font-semibold text-accent"
target="_blank"
/>
),
HeartIcon: <i className="i-mgc-heart-cute-fi" />,
}}
/>
</p>
{activeSetting.path === "about" && (
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">
<Trans
ns="settings"
i18nKey="common.give_star"
components={{
Link: (
<a
href={`${repository.url}`}
className="font-semibold text-accent"
target="_blank"
/>
),
HeartIcon: <i className="i-mgc-heart-cute-fi" />,
}}
/>
</p>
)}
</ScrollArea.ScrollArea>
</Suspense>
)

View File

@ -183,6 +183,7 @@ const SettingItemButtonImpl = (props: {
"my-0.5 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-text",
isActive && "!bg-theme-item-active !text-text",
!IN_ELECTRON && "duration-200 hover:bg-theme-item-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30",
disabled && "opacity-50",
disabledByConfig && "cursor-not-allowed",
)}

View File

@ -60,7 +60,8 @@ export const SettingNotifications = () => {
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">{t.settings("notifications.channel")}</h3>
<span className="text-xs text-text-tertiary">
<span>{data?.data?.length || 0}</span> <span>{t.common("words.items")}</span>
<span>{data?.data?.length || 0}</span>{" "}
<span>{t.common("words.items", { count: data?.data?.length || 0 })}</span>
</span>
</div>
@ -74,7 +75,12 @@ export const SettingNotifications = () => {
{!isLoading && (!data?.data || data.data.length === 0) ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-material-medium py-12">
<i className="i-mgc-notification-cute-re mb-3 text-4xl text-text-quaternary" />
<p className="text-sm text-text-tertiary">No notification channels</p>
<p className="text-sm font-medium text-text">
{t.settings("notifications.empty.title")}
</p>
<p className="mt-1 max-w-sm px-6 text-center text-sm text-text-secondary">
{t.settings("notifications.empty.description")}
</p>
</div>
) : (
<ScrollArea.ScrollArea viewportClassName="max-h-[400px]">

View File

@ -7,7 +7,7 @@ import { m } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { memo, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router"
import { useNavigate } from "react-router"
import { toast } from "sonner"
import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar"
@ -27,6 +27,7 @@ import { ProfileButton } from "~/modules/user/ProfileButton"
export const SubscriptionColumnHeader = memo(() => {
const timelineId = useRouteParamsSelector((s) => s.timelineId)
const navigateBackHome = useBackHome(timelineId)
const navigate = useNavigate()
const normalStyle = !window.electron || window.electron.process.platform !== "darwin"
const { t } = useTranslation()
return (
@ -52,15 +53,14 @@ export const SubscriptionColumnHeader = memo(() => {
</LogoContextMenu>
)}
<div className="relative flex items-center gap-2" onClick={stopPropagation}>
<Link to="/discover" tabIndex={-1}>
<ActionButton
data-testid="subscription-discover-trigger"
shortcut="$mod+T"
tooltip={t("words.discover")}
>
<i className="i-mgc-add-cute-re size-5 text-text-secondary" />
</ActionButton>
</Link>
<ActionButton
data-testid="subscription-discover-trigger"
shortcut="$mod+T"
tooltip={t("words.discover")}
onClick={() => navigate("/discover")}
>
<i className="i-mgc-add-cute-re size-5 text-text-secondary" />
</ActionButton>
<ProfileButton method="modal" animatedAvatar />
<LayoutActionButton />

View File

@ -157,6 +157,7 @@ const ViewAllSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
aria-pressed={isActive}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
key={item.name}
tooltip={t(item.name, { ns: "common" })}
@ -219,6 +220,7 @@ const ViewSwitchButton: FC<{
return (
<ActionButton
data-testid={getTimelineTabTestId(item.name)}
aria-pressed={isActive}
shortcutScope={FocusablePresets.isNotFloatingLayerScope}
ref={setNodeRef}
key={item.name}

View File

@ -18,6 +18,7 @@ import {
import { CSS } from "@dnd-kit/utilities"
import { Button } from "@follow/components/ui/button/index.js"
import { getView } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import type { CSSProperties, ReactNode } from "react"
import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
@ -27,16 +28,31 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { parseView } from "~/hooks/biz/useRouteParams"
import { useTimelineList } from "~/hooks/biz/useTimelineList"
function ContainerDroppable({ id, children }: { id: "visible" | "hidden"; children: ReactNode }) {
function ContainerDroppable({
id,
children,
emptyLabel,
hasItems,
}: {
id: "visible" | "hidden"
children: ReactNode
emptyLabel: string
hasItems: boolean
}) {
const { setNodeRef, isOver } = useDroppable({ id, data: { container: id } })
return (
<div
ref={setNodeRef}
className={`flex min-h-[120px] w-full flex-wrap items-center justify-center rounded-lg border border-border bg-material-ultra-thin p-2 pb-6 shadow-sm ${
isOver ? "outline outline-1 outline-orange-400" : ""
}`}
className={cn(
"flex min-h-[120px] w-full flex-col items-stretch justify-center rounded-xl border border-border bg-material-ultra-thin p-3 shadow-sm transition-colors",
isOver && "border-accent/50 bg-accent/5 ring-2 ring-accent/20",
)}
>
{children}
{hasItems ? (
children
) : (
<p className="px-3 py-6 text-center text-sm text-text-tertiary">{emptyLabel}</p>
)}
</div>
)
}
@ -55,7 +71,7 @@ function TabItem({ id }: { id: UniqueIdentifier }) {
const meta = getViewMeta(String(id))
const { t } = useTranslation()
return (
<div className="flex w-full items-center gap-2 rounded-md p-2 hover:bg-material-opaque">
<div className="flex w-full items-center gap-2 rounded-lg border border-transparent bg-background/60 p-2.5 hover:bg-material-opaque">
<div className="flex size-6 items-center justify-center text-lg">{meta.icon}</div>
<div className="text-callout text-text-secondary">
{t(meta.name as any, { ns: "common" })}
@ -65,6 +81,8 @@ function TabItem({ id }: { id: UniqueIdentifier }) {
}
function SortableTabItem({ id }: { id: UniqueIdentifier }) {
const { t } = useTranslation("app")
const meta = getViewMeta(String(id))
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
})
@ -79,7 +97,11 @@ function SortableTabItem({ id }: { id: UniqueIdentifier }) {
<div
ref={setNodeRef}
style={style}
className={isDragging ? "cursor-grabbing" : "cursor-grab"}
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30",
)}
aria-label={`${t("sidebar.timeline_tabs.drag_tab")}: ${t(meta.name as any, { ns: "common" })}`}
{...attributes}
{...listeners}
>
@ -96,6 +118,7 @@ function useResolvedTimelineTabs() {
}
const TimelineTabsSettings = () => {
const { t } = useTranslation(["app", "common", "settings"])
const { visible, hidden } = useResolvedTimelineTabs()
const commitTimelineTabs = useCallback(
@ -175,6 +198,12 @@ const TimelineTabsSettings = () => {
className="mx-auto w-[600px] max-w-full space-y-4 overflow-hidden pt-2"
onPointerDown={(e) => e.stopPropagation()}
>
<div className="space-y-1 px-1">
<p className="text-sm text-text-secondary">
{t("appearance.customize_sub_tabs.description", { ns: "settings" })}
</p>
<p className="text-xs text-text-tertiary">{t("sidebar.timeline_tabs.instructions")}</p>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@ -183,8 +212,14 @@ const TimelineTabsSettings = () => {
>
<div className="space-y-4">
<div>
<h3 className="mb-2 text-subheadline font-medium text-text">Visible</h3>
<ContainerDroppable id="visible">
<h3 className="mb-2 text-subheadline font-medium text-text">
{t("sidebar.timeline_tabs.visible")}
</h3>
<ContainerDroppable
id="visible"
emptyLabel={t("sidebar.timeline_tabs.empty_visible")}
hasItems={visible.length > 0}
>
<SortableContext items={visible} strategy={verticalListSortingStrategy}>
{visible.map((id) => (
<SortableTabItem key={id} id={id} />
@ -194,8 +229,14 @@ const TimelineTabsSettings = () => {
</div>
<div>
<h3 className="mb-2 text-subheadline font-medium text-text">Hidden</h3>
<ContainerDroppable id="hidden">
<h3 className="mb-2 text-subheadline font-medium text-text">
{t("sidebar.timeline_tabs.hidden")}
</h3>
<ContainerDroppable
id="hidden"
emptyLabel={t("sidebar.timeline_tabs.empty_hidden")}
hasItems={hidden.length > 0}
>
<SortableContext items={hidden} strategy={verticalListSortingStrategy}>
{hidden.map((id) => (
<SortableTabItem key={id} id={id} />
@ -209,6 +250,7 @@ const TimelineTabsSettings = () => {
<div className="flex justify-end">
<Button
variant="outline"
disabled={visible.length === 0 && hidden.length === 0}
onClick={() => {
setUISetting("timelineTabs", {
visible: [],
@ -216,7 +258,7 @@ const TimelineTabsSettings = () => {
})
}}
>
Reset to default
{t("sidebar.timeline_tabs.reset")}
</Button>
</div>
</div>
@ -225,13 +267,14 @@ const TimelineTabsSettings = () => {
export const useShowTimelineTabsSettingsModal = () => {
const { present } = useModalStack()
const { t } = useTranslation("settings")
return useCallback(() => {
present({
id: "timeline-tabs-settings",
title: "Customize View Tabs",
title: t("appearance.customize_sub_tabs.label"),
content: () => <TimelineTabsSettings />,
overlay: true,
clickOutsideToDismiss: true,
})
}, [present])
}, [present, t])
}

View File

@ -19,7 +19,7 @@ interface SectionProps {
}
function Section({ children, className }: SectionProps) {
return <section className={cn("mx-auto w-full max-w-6xl", className)}>{children}</section>
return <section className={cn("mx-auto w-full max-w-5xl", className)}>{children}</section>
}
// ============================================================================
@ -33,25 +33,21 @@ export function Component() {
const hasSearchData = useHasDiscoverSearchData()
return (
<div className="flex size-full flex-col px-6 py-8">
{/* Hero Section */}
<Section className="mb-12">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold text-text">{t("words.discover")}</h1>
<p className="text-sm text-text-secondary">{t("discover.tips.search_keyword")}</p>
<div className="flex size-full flex-col p-6">
<Section className="mb-8">
<div className="rounded-[28px] border border-fill-secondary bg-material-ultra-thin px-6 py-8 shadow-sm">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold text-text">{t("words.discover")}</h1>
<p className="text-sm text-text-secondary">{t("discover.tips.search_keyword")}</p>
</div>
<div className="mt-6 flex flex-col items-center">
<UnifiedDiscoverForm />
</div>
</div>
</Section>
{/* Search Section */}
<Section className="mb-12">
<div className="flex flex-col items-center">
<UnifiedDiscoverForm />
</div>
</Section>
{/* Discovery Section - Hide when searching */}
{!hasSearchData && (
<Section>
<Section className="mt-8">
<AppErrorBoundary errorType={ErrorComponentType.RSSHubDiscoverError}>
<DiscoveryContent />
</AppErrorBoundary>

View File

@ -474,7 +474,14 @@
"sidebar.feed_column.context_menu.unsubscribe_category": "Unsubscribe All in Category",
"sidebar.select_sort_method": "Select a sort method",
"sidebar.timeline_tabs.customize": "Customize View Tabs...",
"sidebar.timeline_tabs.drag_tab": "Drag timeline tab",
"sidebar.timeline_tabs.empty_hidden": "Drag tabs here to hide them from the sidebar.",
"sidebar.timeline_tabs.empty_visible": "Drag tabs here to show them in the sidebar.",
"sidebar.timeline_tabs.hidden": "Hidden",
"sidebar.timeline_tabs.hide_tab": "Hide this view",
"sidebar.timeline_tabs.instructions": "Drag tabs between sections to reorder, show, or hide them.",
"sidebar.timeline_tabs.reset": "Reset to default",
"sidebar.timeline_tabs.visible": "Visible",
"signin.continue_with": "Continue with {{provider}}",
"signin.sign_in_to": "Sign in to",
"signin.sign_up_to": "Sign up to",

View File

@ -473,7 +473,14 @@
"sidebar.feed_column.context_menu.unsubscribe_category": "Se désabonner de tout dans la catégorie",
"sidebar.select_sort_method": "Sélectionner une méthode de tri",
"sidebar.timeline_tabs.customize": "Personnaliser les onglets de vue...",
"sidebar.timeline_tabs.drag_tab": "Faire glisser l'onglet de la chronologie",
"sidebar.timeline_tabs.empty_hidden": "Faites glisser des onglets ici pour les masquer de la barre latérale.",
"sidebar.timeline_tabs.empty_visible": "Faites glisser des onglets ici pour les afficher dans la barre latérale.",
"sidebar.timeline_tabs.hidden": "Masqué",
"sidebar.timeline_tabs.hide_tab": "Masquer cette vue",
"sidebar.timeline_tabs.instructions": "Faites glisser les onglets entre les sections pour les réorganiser, les afficher ou les masquer.",
"sidebar.timeline_tabs.reset": "Réinitialiser par défaut",
"sidebar.timeline_tabs.visible": "Visible",
"signin.continue_with": "Continuer avec {{provider}}",
"signin.sign_in_to": "Se connecter à",
"signin.sign_up_to": "S'inscrire à",

View File

@ -474,7 +474,14 @@
"sidebar.feed_column.context_menu.unsubscribe_category": "カテゴリー内の購読をすべて解除",
"sidebar.select_sort_method": "並べ替え方法を選択",
"sidebar.timeline_tabs.customize": "ビュータブをカスタマイズ...",
"sidebar.timeline_tabs.drag_tab": "タイムラインタブをドラッグ",
"sidebar.timeline_tabs.empty_hidden": "ここにドラッグするとサイドバーから非表示になります。",
"sidebar.timeline_tabs.empty_visible": "ここにドラッグするとサイドバーに表示されます。",
"sidebar.timeline_tabs.hidden": "非表示",
"sidebar.timeline_tabs.hide_tab": "このビューを非表示にする",
"sidebar.timeline_tabs.instructions": "セクション間でタブをドラッグして、並び替え、表示、非表示を切り替えます。",
"sidebar.timeline_tabs.reset": "デフォルトに戻す",
"sidebar.timeline_tabs.visible": "表示中",
"signin.continue_with": "{{provider}} で続ける",
"signin.sign_in_to": "サインイン",
"signin.sign_up_to": "サインアップ",

View File

@ -474,7 +474,14 @@
"sidebar.feed_column.context_menu.unsubscribe_category": "取消分类内所有订阅",
"sidebar.select_sort_method": "选择排序方法",
"sidebar.timeline_tabs.customize": "自定义视图标签...",
"sidebar.timeline_tabs.drag_tab": "拖动时间线标签",
"sidebar.timeline_tabs.empty_hidden": "将标签拖到这里即可从侧栏隐藏。",
"sidebar.timeline_tabs.empty_visible": "将标签拖到这里即可在侧栏显示。",
"sidebar.timeline_tabs.hidden": "已隐藏",
"sidebar.timeline_tabs.hide_tab": "隐藏此视图",
"sidebar.timeline_tabs.instructions": "在两个区域之间拖动标签,即可重新排序、显示或隐藏它们。",
"sidebar.timeline_tabs.reset": "恢复默认",
"sidebar.timeline_tabs.visible": "已显示",
"signin.continue_with": "使用 {{provider}} 登录",
"signin.sign_in_to": "登录",
"signin.sign_up_to": "注册",

View File

@ -474,7 +474,14 @@
"sidebar.feed_column.context_menu.unsubscribe_category": "取消分類內所有訂閱",
"sidebar.select_sort_method": "選擇排序方式",
"sidebar.timeline_tabs.customize": "自訂檢視分頁...",
"sidebar.timeline_tabs.drag_tab": "拖曳時間軸標籤",
"sidebar.timeline_tabs.empty_hidden": "將標籤拖曳到這裡即可從側欄隱藏。",
"sidebar.timeline_tabs.empty_visible": "將標籤拖曳到這裡即可在側欄顯示。",
"sidebar.timeline_tabs.hidden": "已隱藏",
"sidebar.timeline_tabs.hide_tab": "隱藏此檢視",
"sidebar.timeline_tabs.instructions": "在兩個區域之間拖曳標籤,即可重新排序、顯示或隱藏它們。",
"sidebar.timeline_tabs.reset": "恢復預設",
"sidebar.timeline_tabs.visible": "已顯示",
"signin.continue_with": "透過 {{provider}} 登入",
"signin.sign_in_to": "登入",
"signin.sign_up_to": "註冊",

View File

@ -36,6 +36,7 @@
"actions.action_card.block": "Block",
"actions.action_card.block_rules": "Block Rules",
"actions.action_card.custom_filters": "Custom Filters",
"actions.action_card.empty.cta": "Create your first rule",
"actions.action_card.empty.description": "Create your first action rule to automatically process your feeds.",
"actions.action_card.empty.start": "Start here!",
"actions.action_card.empty.title": "No Actions Yet",
@ -573,6 +574,8 @@
"lists.view": "View",
"notifications.channel": "Channel",
"notifications.current": "(current client)",
"notifications.empty.description": "Notification channels will appear here after you enable notifications on this device.",
"notifications.empty.title": "No notification channels",
"notifications.info": "Folo offers robust and versatile notification features through <ActionsLink>Actions</ActionsLink>. You can customize notification for specific feeds, views, or keywords. Below are your registered notification channels.",
"notifications.test": "Test Notification",
"notifications.test_success": "Test notification sent successfully.",
@ -816,6 +819,8 @@
"wallet.sidebar_title": "Power",
"wallet.transactions.amount": "Amount",
"wallet.transactions.date": "Date",
"wallet.transactions.empty.description": "Tips, purchases, withdrawals, and airdrops will appear here once they happen.",
"wallet.transactions.empty.title": "No transactions yet",
"wallet.transactions.from": "From",
"wallet.transactions.more": "View more through the blockchain explorer.",
"wallet.transactions.noTransactions": "No transactions",

View File

@ -36,6 +36,7 @@
"actions.action_card.block": "Bloquer",
"actions.action_card.block_rules": "Règles de blocage",
"actions.action_card.custom_filters": "Filtres personnalisés",
"actions.action_card.empty.cta": "Créer votre première règle",
"actions.action_card.empty.description": "Créez votre première règle d'action pour traiter automatiquement vos flux.",
"actions.action_card.empty.start": "Commencez ici !",
"actions.action_card.empty.title": "Aucune action pour le moment",
@ -573,6 +574,8 @@
"lists.view": "Vue",
"notifications.channel": "Canal",
"notifications.current": "(client actuel)",
"notifications.empty.description": "Les canaux de notification apparaîtront ici une fois les notifications activées sur cet appareil.",
"notifications.empty.title": "Aucun canal de notification",
"notifications.info": "Folo offre des fonctionnalités de notification robustes et polyvalentes via <ActionsLink>Actions</ActionsLink>. Vous pouvez personnaliser la notification pour des flux, des vues ou des mots-clés spécifiques. Ci-dessous vos canaux de notification enregistrés.",
"notifications.test": "Notification de test",
"notifications.test_success": "Notification de test envoyée avec succès.",
@ -797,6 +800,8 @@
"wallet.sidebar_title": "Puissance",
"wallet.transactions.amount": "Montant",
"wallet.transactions.date": "Date",
"wallet.transactions.empty.description": "Les pourboires, achats, retraits et airdrops apparaîtront ici lorsqu'ils auront lieu.",
"wallet.transactions.empty.title": "Aucune transaction pour le moment",
"wallet.transactions.from": "De",
"wallet.transactions.more": "Voir plus via l'explorateur de blockchain.",
"wallet.transactions.noTransactions": "Aucune transaction",

View File

@ -36,6 +36,7 @@
"actions.action_card.block": "ブロック",
"actions.action_card.block_rules": "ブロックルール",
"actions.action_card.custom_filters": "カスタムフィルター",
"actions.action_card.empty.cta": "最初のルールを作成",
"actions.action_card.empty.description": "最初のアクションルールを作成して、フィードを自動的に処理します。",
"actions.action_card.empty.start": "ここから始めましょう!",
"actions.action_card.empty.title": "アクションはまだありません",
@ -573,6 +574,8 @@
"lists.view": "表示",
"notifications.channel": "チャンネル",
"notifications.current": "(現在のクライアント)",
"notifications.empty.description": "このデバイスで通知を有効にすると、通知チャンネルがここに表示されます。",
"notifications.empty.title": "通知チャンネルはありません",
"notifications.info": "Foloは<ActionsLink>アクション</ActionsLink>を通じて堅牢で多機能な通知機能を提供します。特定のフィード、ビュー、キーワードの通知をカスタマイズできます。以下は登録された通知チャンネルです。",
"notifications.test": "テスト通知",
"notifications.test_success": "テスト通知が正常に送信されました。",
@ -816,6 +819,8 @@
"wallet.sidebar_title": "Power",
"wallet.transactions.amount": "金額",
"wallet.transactions.date": "日付",
"wallet.transactions.empty.description": "チップ、購入、出金、エアドロップの履歴が発生するとここに表示されます。",
"wallet.transactions.empty.title": "まだ取引はありません",
"wallet.transactions.from": "送信元",
"wallet.transactions.more": "blockchain explorerで詳細を表示する",
"wallet.transactions.noTransactions": "トランザクションなし",

View File

@ -36,6 +36,7 @@
"actions.action_card.block": "屏蔽",
"actions.action_card.block_rules": "阻止规则",
"actions.action_card.custom_filters": "指定条件",
"actions.action_card.empty.cta": "创建第一条规则",
"actions.action_card.empty.description": "创建首个自动化规则以自动处理你的订阅源",
"actions.action_card.empty.start": "从此处开始!",
"actions.action_card.empty.title": "尚无自动化规则",
@ -573,6 +574,8 @@
"lists.view": "视图",
"notifications.channel": "渠道",
"notifications.current": "(当前客户端)",
"notifications.empty.description": "当你在当前设备上启用通知后,通知渠道会显示在这里。",
"notifications.empty.title": "暂无通知渠道",
"notifications.info": "Folo 通过<ActionsLink>自动化</ActionsLink>提供强大且灵活的通知功能。你可以为特定的订阅源、视图或关键字自定义通知。以下是已注册的通知渠道。",
"notifications.test": "测试通知",
"notifications.test_success": "测试通知发送成功。",
@ -816,6 +819,8 @@
"wallet.sidebar_title": "Power",
"wallet.transactions.amount": "数额",
"wallet.transactions.date": "日期",
"wallet.transactions.empty.description": "打赏、购买、提现和空投等记录发生后会显示在这里。",
"wallet.transactions.empty.title": "暂无交易记录",
"wallet.transactions.from": "发送者",
"wallet.transactions.more": "通过区块链浏览器查看更多交易…",
"wallet.transactions.noTransactions": "无交易记录",

View File

@ -36,6 +36,7 @@
"actions.action_card.block": "封鎖",
"actions.action_card.block_rules": "封鎖規則",
"actions.action_card.custom_filters": "自訂過濾條件",
"actions.action_card.empty.cta": "建立第一條規則",
"actions.action_card.empty.description": "建立首個自動化規則以自動處理您的訂閱內容。",
"actions.action_card.empty.start": "從此處開始!",
"actions.action_card.empty.title": "尚無自動化規則",
@ -573,6 +574,8 @@
"lists.view": "查看",
"notifications.channel": "管道",
"notifications.current": "(當前客户端)",
"notifications.empty.description": "當您在這台裝置上啟用通知後,通知管道會顯示在這裡。",
"notifications.empty.title": "尚無通知管道",
"notifications.info": "Folo 通過<ActionsLink>自動化</ActionsLink>提供強大且靈活的通知功能。你可以為特定的 RSS 摘要、視圖或關鍵字自定義通知。以下是已註冊的通知管道。",
"notifications.test": "測試通知",
"notifications.test_success": "測試通知發送成功。",
@ -797,6 +800,8 @@
"wallet.sidebar_title": "Power",
"wallet.transactions.amount": "額度",
"wallet.transactions.date": "日期",
"wallet.transactions.empty.description": "當打賞、購買、提領與空投等記錄發生後,會顯示在這裡。",
"wallet.transactions.empty.title": "尚無交易紀錄",
"wallet.transactions.from": "發送者",
"wallet.transactions.more": "通過區塊鏈瀏覽器查看更多交易…",
"wallet.transactions.noTransactions": "無交易紀錄",

View File

@ -109,6 +109,7 @@ export const ActionButton = ({
"no-drag-region pointer-events-auto inline-flex items-center justify-center",
active && typeof icon !== "function" && "bg-zinc-500/15 hover:bg-zinc-500/20",
"hover:bg-theme-item-hover data-[state=open]:bg-theme-item-active rounded-md duration-200",
"focus-visible:ring-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
clickableDisabled && "cursor-not-allowed opacity-50",
shouldHighlightMotion &&
@ -127,6 +128,8 @@ export const ActionButton = ({
}}
type="button"
disabled={disabled}
aria-busy={loading || undefined}
aria-disabled={disabled || clickableDisabled || undefined}
onClick={
onClick
? async (e) => {