diff --git a/apps/desktop/e2e/scripts/capture-ui-audit.ts b/apps/desktop/e2e/scripts/capture-ui-audit.ts new file mode 100644 index 000000000..b1b16e2e1 --- /dev/null +++ b/apps/desktop/e2e/scripts/capture-ui-audit.ts @@ -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() diff --git a/apps/desktop/e2e/support/app.ts b/apps/desktop/e2e/support/app.ts index 2e2b6f385..8ace2e891 100644 --- a/apps/desktop/e2e/support/app.ts +++ b/apps/desktop/e2e/support/app.ts @@ -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 diff --git a/apps/desktop/e2e/support/auth-bootstrap.ts b/apps/desktop/e2e/support/auth-bootstrap.ts new file mode 100644 index 000000000..3ee0d623d --- /dev/null +++ b/apps/desktop/e2e/support/auth-bootstrap.ts @@ -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 + 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) +} diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index fb6778774..42958d6ad 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -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 { @@ -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) { diff --git a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx index 7fd1ab10d..ebd9ccaa7 100644 --- a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx @@ -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 ( -
-
- {/* Simple icon */} -
- +
+
+
+
@@ -63,25 +62,23 @@ const EmptyActionPlaceholder = () => { {t("actions.action_card.empty.description")}

-
- -
- {t("actions.action_card.empty.start")} - + +
+ + + + {t("words.documentation", { ns: "common" })} +
- +
) } @@ -164,7 +161,7 @@ export const ActionSetting = () => {
) : ( - + )} ) diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 52724d905..b0cda5452 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -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({ {t("login.email")} - + @@ -245,7 +254,15 @@ export function LoginWithPassword({ - + @@ -268,17 +285,21 @@ export function LoginWithPassword({ -
- If you don't have an account,{" "} - +
+ onLoginStateChange("register")} + /> + ), + }} + />
) @@ -396,7 +417,16 @@ export function RegisterForm({ {t("register.email")} - + @@ -413,7 +443,15 @@ export function RegisterForm({ : `${t("register.password")} (${t("register.password_optional")})`} - + @@ -430,7 +468,15 @@ export function RegisterForm({ : `${t("register.confirm_password")} (${t("register.password_optional")})`} - + @@ -452,17 +498,21 @@ export function RegisterForm({ -
- If you already have an account,{" "} - +
+ onLoginStateChange("login")} + /> + ), + }} + />
) diff --git a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx index 4db95789f..081805adc 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/LoginModalContent.tsx @@ -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 && ( )} {isEmail ? ( @@ -181,13 +182,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
{/* Login Providers */}
- {visibleProviders.map(([key, provider], index) => ( - + {visibleProviders.map(([key, provider]) => ( +
- +
))}
@@ -238,9 +238,9 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
@@ -249,7 +249,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { @@ -257,7 +257,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => { diff --git a/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx b/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx index c89db4384..7f567b330 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/TokenModal.tsx @@ -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} /> diff --git a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx index c8fc85498..76bfd021b 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/DiscoveryContent.tsx @@ -42,9 +42,8 @@ export function DiscoveryContent() { } return ( -
- {/* Segment Toggle - Centered */} -
+
+
setActiveView(value as DiscoveryView)} @@ -70,28 +69,23 @@ export function DiscoveryContent() { /> - {/* Filters Bar - Inside Content Area */} -
-
- - {t("words.language")}: - - tCommon(item.label as any)} - renderValue={(item) => tCommon(item.label as any)} - /> -
+
+ + {t("words.language")}: + + tCommon(item.label as any)} + renderValue={(item) => tCommon(item.label as any)} + />
- {/* Content Area with Filters */} -
- {/* Content */} +
{activeView === "trending" ? ( ) : ( diff --git a/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx b/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx index b1ee06e27..f58750bb6 100644 --- a/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx +++ b/apps/desktop/layer/renderer/src/modules/discover/UnifiedDiscoverForm.tsx @@ -301,7 +301,7 @@ export function UnifiedDiscoverForm() { className="w-full max-w-2xl" data-testid="discover-form" > -
+
{ const { t } = useTranslation("settings") return ( -
-

- , - strong: , - }} - /> -

-
- +
+
+
+
+ +
+

+ , + strong: , + }} + /> +

+
+ +
+ +
) diff --git a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx index b64275465..7a5d3ddca 100644 --- a/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/my-wallet-section/index.tsx @@ -36,9 +36,14 @@ export const MyWalletSection = ({ className }: { className?: string }) => { return } return ( -
+
-
+
@@ -46,7 +51,7 @@ export const MyWalletSection = ({ className }: { className?: string }) => {
- +
{t("wallet.balance.withdrawable")} diff --git a/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx b/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx index a2a7226b9..44cfc23c2 100644 --- a/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx +++ b/apps/desktop/layer/renderer/src/modules/power/transaction-section/TransactionsSection.tsx @@ -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 ( -
+
setType(val)}> @@ -40,8 +48,8 @@ export const TransactionsSection: Component = ({ className }) => { ))} - - {!!transactions.data?.length && ( + {hasTransactions ? : null} + {hasTransactions && ( { )} - {(transactions.isFetching || !transactions.data?.length) && ( -
+ {(transactions.isFetching || !hasTransactions) && ( +
{transactions.isFetching ? ( ) : ( - t("wallet.transactions.noTransactions") +
+ +

+ {t("wallet.transactions.empty.title")} +

+

+ {t("wallet.transactions.empty.description")} +

+
)}
)} diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx index 0c88bcaae..67f2783b3 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/SettingModalContent.tsx @@ -195,22 +195,24 @@ const Content: FC<{
-

- - ), - HeartIcon: , - }} - /> -

+ {activeSetting.path === "about" && ( +

+ + ), + HeartIcon: , + }} + /> +

+ )} ) diff --git a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx index a7a89c39f..982461221 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/modal/layout.tsx @@ -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", )} diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx index 4953469a2..32bd69f74 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/notifications.tsx @@ -60,7 +60,8 @@ export const SettingNotifications = () => {

{t.settings("notifications.channel")}

- {data?.data?.length || 0} {t.common("words.items")} + {data?.data?.length || 0}{" "} + {t.common("words.items", { count: data?.data?.length || 0 })}
@@ -74,7 +75,12 @@ export const SettingNotifications = () => { {!isLoading && (!data?.data || data.data.length === 0) ? (
-

No notification channels

+

+ {t.settings("notifications.empty.title")} +

+

+ {t.settings("notifications.empty.description")} +

) : ( diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx index 6693a23e3..c9ed343a3 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionColumnHeader.tsx @@ -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(() => { )}
- - - - - + navigate("/discover")} + > + + diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx index e9241bc5d..ae4d2d075 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SubscriptionTabButton.tsx @@ -157,6 +157,7 @@ const ViewAllSwitchButton: FC<{ return ( - {children} + {hasItems ? ( + children + ) : ( +

{emptyLabel}

+ )}
) } @@ -55,7 +71,7 @@ function TabItem({ id }: { id: UniqueIdentifier }) { const meta = getViewMeta(String(id)) const { t } = useTranslation() return ( -
+
{meta.icon}
{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 }) {
@@ -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()} > +
+

+ {t("appearance.customize_sub_tabs.description", { ns: "settings" })} +

+

{t("sidebar.timeline_tabs.instructions")}

+
{ >
-

Visible

- +

+ {t("sidebar.timeline_tabs.visible")} +

+ 0} + > {visible.map((id) => ( @@ -194,8 +229,14 @@ const TimelineTabsSettings = () => {
-

Hidden

- +

+ {t("sidebar.timeline_tabs.hidden")} +

+ 0} + > {hidden.map((id) => ( @@ -209,6 +250,7 @@ const TimelineTabsSettings = () => {
@@ -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: () => , overlay: true, clickOutsideToDismiss: true, }) - }, [present]) + }, [present, t]) } diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx index 35cbae226..34a6de5ed 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx @@ -19,7 +19,7 @@ interface SectionProps { } function Section({ children, className }: SectionProps) { - return
{children}
+ return
{children}
} // ============================================================================ @@ -33,25 +33,21 @@ export function Component() { const hasSearchData = useHasDiscoverSearchData() return ( -
- {/* Hero Section */} -
-
-

{t("words.discover")}

-

{t("discover.tips.search_keyword")}

+
+
+
+
+

{t("words.discover")}

+

{t("discover.tips.search_keyword")}

+
+
+ +
- {/* Search Section */} -
-
- -
-
- - {/* Discovery Section - Hide when searching */} {!hasSearchData && ( -
+
diff --git a/locales/app/en.json b/locales/app/en.json index aabf420ae..b1568b40b 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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", diff --git a/locales/app/fr-FR.json b/locales/app/fr-FR.json index 08c2eede7..cba7b105d 100644 --- a/locales/app/fr-FR.json +++ b/locales/app/fr-FR.json @@ -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 à", diff --git a/locales/app/ja.json b/locales/app/ja.json index 5e957d2f7..1d6afbcd6 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -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": "サインアップ", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 2ea6d51f5..e26bf395e 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -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": "注册", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index e2eb02f53..d70ca8a59 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -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": "註冊", diff --git a/locales/settings/en.json b/locales/settings/en.json index 9bc23987a..bfd1370f5 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -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 Actions. 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", diff --git a/locales/settings/fr-FR.json b/locales/settings/fr-FR.json index cd698d4ce..8e11ad0ac 100644 --- a/locales/settings/fr-FR.json +++ b/locales/settings/fr-FR.json @@ -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 Actions. 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", diff --git a/locales/settings/ja.json b/locales/settings/ja.json index 7e48d80f4..cacd2a8a5 100644 --- a/locales/settings/ja.json +++ b/locales/settings/ja.json @@ -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はアクションを通じて堅牢で多機能な通知機能を提供します。特定のフィード、ビュー、キーワードの通知をカスタマイズできます。以下は登録された通知チャンネルです。", "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": "トランザクションなし", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 889f67f62..a4950ef65 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -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 通过自动化提供强大且灵活的通知功能。你可以为特定的订阅源、视图或关键字自定义通知。以下是已注册的通知渠道。", "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": "无交易记录", diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json index 4afb1e831..ce0726ecc 100644 --- a/locales/settings/zh-TW.json +++ b/locales/settings/zh-TW.json @@ -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 通過自動化提供強大且靈活的通知功能。你可以為特定的 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": "無交易紀錄", diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx index 3414aed6c..bdbc5fb97 100644 --- a/packages/internal/components/src/ui/button/action-button.tsx +++ b/packages/internal/components/src/ui/button/action-button.tsx @@ -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) => {