fix(desktop): improve self-test coverage and ui polish
This commit is contained in:
parent
df83cf62ea
commit
aea75fe57b
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 à",
|
||||
|
|
|
|||
|
|
@ -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": "サインアップ",
|
||||
|
|
|
|||
|
|
@ -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": "注册",
|
||||
|
|
|
|||
|
|
@ -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": "註冊",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "トランザクションなし",
|
||||
|
|
|
|||
|
|
@ -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": "无交易记录",
|
||||
|
|
|
|||
|
|
@ -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": "無交易紀錄",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue