feat: Android download page, analysis retry and a collapsible search
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
- /android is a public page with the latest APK, the server address to copy and three install steps; linked from the sign-in screen and the profile menu - failed audio analysis can be retried from the library page, which also shows the most common error; the analyzer no longer burns attempts on files that are missing because the disk is not mounted - the top bar search folds into a round button and slides open on click, "/" or Ctrl+K Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d49cb3f19
commit
fda6ed48ab
27 files changed
+757
-277
No files matched your search
@@ -21,6 +21,7 @@ import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||
import { SettingsPage } from '../pages/SettingsPage';
|
||||
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
androidAppRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatBytes } from '../shared/lib/format';
|
||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||
import { Icon } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
const session = useSession();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const latest = useQuery({
|
||||
queryKey: ['android-latest'],
|
||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const server = location.origin;
|
||||
const copy = () =>
|
||||
void navigator.clipboard?.writeText(server).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
const r = latest.data;
|
||||
|
||||
return (
|
||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||
<span className={s.badge}>Android</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : r ? (
|
||||
<>
|
||||
<a className={s.download} href={r.download_url} download>
|
||||
<Icon src={downloadIcon} size={22} />
|
||||
<span>
|
||||
{t('app.download')}
|
||||
<span className={s.downloadMeta}>
|
||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<ol className={s.steps}>
|
||||
<li>{t('app.step_install')}</li>
|
||||
<li>
|
||||
{t('app.step_server')}
|
||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||
<code>{server}</code>
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li>{t('app.step_updates')}</li>
|
||||
</ol>
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{r.sha256}</code>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,15 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
||||
<AuthLayout
|
||||
footer={
|
||||
info.data ? (
|
||||
<>
|
||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -32,6 +32,11 @@ export function LibraryPage() {
|
||||
const reindex = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const retry = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
||||
});
|
||||
const d = status.data;
|
||||
const tiles = d
|
||||
? ([
|
||||
@@ -77,7 +82,19 @@ export function LibraryPage() {
|
||||
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
||||
})}
|
||||
/>
|
||||
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
|
||||
{d.analysis.failed > 0 && (
|
||||
<div className={l.retryRow}>
|
||||
<span className={l.scanError}>
|
||||
{t('library.analysis_failed', { count: d.analysis.failed })}
|
||||
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||
</span>
|
||||
{admin && (
|
||||
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||
{t('library.analysis_retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -118,6 +118,20 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.errorDetail {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text3);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -161,6 +161,83 @@
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
/* Страница Android-приложения (/android). */
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow);
|
||||
transition:
|
||||
background 0.2s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.download:hover {
|
||||
background: var(--accent-fill2);
|
||||
}
|
||||
|
||||
.download:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.downloadMeta {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--text2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.server {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.details code {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
|
||||
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
|
||||
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
|
||||
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
||||
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
||||
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
dislikeFillIcon,
|
||||
dislikeIcon,
|
||||
settingsIcon,
|
||||
androidIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
@@ -102,8 +103,8 @@ function Shell() {
|
||||
<Link to="/" className={s.logoLink}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<SearchBox />
|
||||
<div className={s.topRight}>
|
||||
<SearchBox />
|
||||
<Menu
|
||||
label={t('profile.menu')}
|
||||
triggerClassName={s.profileButton}
|
||||
@@ -122,6 +123,7 @@ function Shell() {
|
||||
}
|
||||
items={[
|
||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
.box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
flex: none;
|
||||
width: 40px;
|
||||
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.expanded {
|
||||
width: min(520px, 46vw);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text3);
|
||||
@@ -16,13 +21,36 @@
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 40px 0 42px;
|
||||
padding: 0 40px 0 40px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
|
||||
.box:not(.expanded) .input {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.box:not(.expanded) .input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.box:not(.expanded) .input:hover {
|
||||
background: var(--surface3);
|
||||
}
|
||||
|
||||
.box:not(.expanded) .kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@@ -56,8 +84,8 @@
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
top: calc(100% + 8px);
|
||||
z-index: 45;
|
||||
max-height: min(70vh, 560px);
|
||||
|
||||
@@ -23,7 +23,10 @@ type Item = {
|
||||
go: () => void;
|
||||
};
|
||||
|
||||
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
||||
/**
|
||||
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
|
||||
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
|
||||
*/
|
||||
export function SearchBox() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
@@ -34,6 +37,9 @@ export function SearchBox() {
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
|
||||
const expanded = focused || q !== '';
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||
@@ -141,7 +147,7 @@ export function SearchBox() {
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
return (
|
||||
<div className={s.box}>
|
||||
<div className={cx(s.box, expanded && s.expanded)}>
|
||||
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -160,8 +166,14 @@ export function SearchBox() {
|
||||
setOpen(true);
|
||||
setActive(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(close, 120)}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
setTimeout(close, 120);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
|
||||
Reference in new issue
Block a user