From 8dfd03f3d20cd69b220e67eb9b3582926b807be9 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 23:40:34 +0800 Subject: [PATCH] feat: add project updates feed and dedicated page Proxy and sanitize Gitea repository data via /api/project-updates Display recent commits and releases preview on the Home page Add /project-updates route for paginated commit history --- DESIGN.md | 20 ++ backend/src/server.ts | 285 +++++++++++++++++ frontend/src/icons.ts | 4 + frontend/src/router/index.ts | 12 + frontend/src/services/api.ts | 49 +++ frontend/src/styles/main.css | 371 ++++++++++++++++++++++ frontend/src/views/HomeView.vue | 130 +++++++- frontend/src/views/ProjectUpdatesView.vue | 284 +++++++++++++++++ system-wordings.ts | 54 ++++ 9 files changed, 1207 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/ProjectUpdatesView.vue diff --git a/DESIGN.md b/DESIGN.md index 5d7fc7c..5cd3625 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -746,6 +746,24 @@ API 暴露边界: - Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。 - 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。 +## 项目更新展示 + +- Home 首页可展示 Pokopia Wiki 站点项目的公开更新信息,用于让访客了解站点代码与发布进展。 +- 完整项目更新页路径为 `/project-updates`,由 Home 首页项目更新预览区的 View All 入口进入。 +- 更新信息来源为公开 Gitea 仓库 `https://git.tootaio.com/Kingsmai/pokopiawiki.tootaio.com`。 +- 前端不得直接读取 Gitea API;后端通过 `GET /api/project-updates` 代理并净化公开仓库数据。 +- 项目更新 API 只返回展示所需字段: + - 仓库:`name`、`fullName`、公开仓库 `url`、`defaultBranch`、`updatedAt`。 + - 最近提交分页:`items`、`nextCursor`、`hasMore`;每条提交只包含 `sha`、`shortSha`、提交标题 `title`、完整提交消息 `message`、`createdAt`、不含邮箱的 `authorName`、公开提交 `url`。 + - 发布版本:`tagName`、`name`、`publishedAt`、公开发布 `url`。 +- 最近提交支持 `limit` 和不透明 `cursor` 增量读取;前端不得依赖 Gitea 的 `page` / `limit` 实现细节。 +- 项目更新 API 不返回 Gitea token、用户邮箱、内部 API URL、内网地址、文件列表、提交统计、Actions 日志、构建日志或调试字段。 +- Home 首页默认展示最近提交预览;用户可通过 View All 进入 `/project-updates` 完整页面。 +- `/project-updates` 按 Life Post 相同的增量方式继续显示更多提交。 +- `/project-updates` 的每条提交默认折叠,仅展示标题、短 SHA、作者和时间;用户可展开单条提交查看完整 Commit Message,并可再次收起。 +- 若仓库后续提供 Release,可展示发布版本。没有 Release 时不展示空发布区块。 +- Gitea 读取失败时不得在前台展示内部错误或调试信息。 + ## 前端交互与 UI - UI 风格以 `DesignGuidelines.html` 为准。 @@ -792,6 +810,7 @@ API 暴露边界: - `/recipes` - `/checklist` - `/life` + - `/project-updates` - `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。 - Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。 - 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。 @@ -806,6 +825,7 @@ API 暴露边界: - `GET /api/languages` - `GET /api/system-wordings` - `GET /api/options` +- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。 - `GET /api/daily-checklist` - `GET /api/pokemon` - `GET /api/pokemon/:id` diff --git a/backend/src/server.ts b/backend/src/server.ts index 9bea4dd..32c3fdc 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -212,6 +212,287 @@ function requestLocale(request: FastifyRequest): string { return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale)); } +type ProjectUpdatesRepository = { + name: string; + fullName: string; + url: string; + defaultBranch: string; + updatedAt: string | null; +}; + +type ProjectUpdateCommit = { + sha: string; + shortSha: string; + title: string; + message: string; + createdAt: string; + authorName: string; + url: string; +}; + +type ProjectUpdateRelease = { + tagName: string; + name: string; + publishedAt: string | null; + url: string; +}; + +type ProjectCommitPage = { + items: ProjectUpdateCommit[]; + nextCursor: string | null; + hasMore: boolean; +}; + +type ProjectUpdatesCursor = { + page: number; + limit: number; +}; + +type ProjectUpdatesResponse = { + repository: ProjectUpdatesRepository; + commits: ProjectCommitPage; + releases: ProjectUpdateRelease[]; +}; + +const projectUpdatesConfig = { + apiBaseUrl: 'https://git.tootaio.com/api/v1', + publicBaseUrl: 'https://git.tootaio.com', + owner: 'Kingsmai', + repo: 'pokopiawiki.tootaio.com', + commitLimit: 5, + maxCommitLimit: 20, + releaseLimit: 3, + timeoutMs: 5000 +} as const; + +function projectRepositoryPath(): string { + return `${encodeURIComponent(projectUpdatesConfig.owner)}/${encodeURIComponent(projectUpdatesConfig.repo)}`; +} + +function projectRepositoryUrl(): string { + return `${projectUpdatesConfig.publicBaseUrl}/${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`; +} + +function projectApiUrl(path = '', params: Record = {}): string { + const apiBaseUrl = projectUpdatesConfig.apiBaseUrl.replace(/\/$/, ''); + const url = new URL(`${apiBaseUrl}/repos/${projectRepositoryPath()}${path}`); + + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, String(value)); + } + + return url.toString(); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function objectField(record: Record | null, key: string): Record | null { + if (!record) return null; + const value = record[key]; + return isObjectRecord(value) ? value : null; +} + +function stringField(record: Record | null, key: string): string | null { + if (!record) return null; + const value = record[key]; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; +} + +function normalizedDate(value: string | null): string | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function projectCommitTitle(message: string | null, fallback: string): string { + const [firstLine] = (message ?? '').split('\n'); + return firstLine?.trim() || fallback; +} + +function projectUpdatesQueryValue(value: string | string[] | undefined): string | null { + const rawValue = Array.isArray(value) ? value[0] : value; + return rawValue?.trim() || null; +} + +function cleanProjectUpdatesLimit(query: Record): number { + const rawLimit = Number(projectUpdatesQueryValue(query.limit)); + if (!Number.isInteger(rawLimit)) { + return projectUpdatesConfig.commitLimit; + } + + return Math.min(Math.max(rawLimit, 1), projectUpdatesConfig.maxCommitLimit); +} + +function encodeProjectUpdatesCursor(page: number, limit: number): string { + return Buffer.from(JSON.stringify({ page, limit }), 'utf8').toString('base64url'); +} + +function decodeProjectUpdatesCursor(cursor: string | null): ProjectUpdatesCursor | null { + if (!cursor) return null; + + try { + const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown; + if (!isObjectRecord(payload)) { + return null; + } + + const { page, limit } = payload; + if ( + typeof page === 'number' && + Number.isInteger(page) && + page > 0 && + typeof limit === 'number' && + Number.isInteger(limit) && + limit > 0 && + limit <= projectUpdatesConfig.maxCommitLimit + ) { + return { page, limit }; + } + + return null; + } catch { + return null; + } +} + +function fallbackProjectRepository(): ProjectUpdatesRepository { + return { + name: projectUpdatesConfig.repo, + fullName: `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`, + url: projectRepositoryUrl(), + defaultBranch: 'main', + updatedAt: null + }; +} + +async function fetchProjectJson(path = '', params: Record = {}): Promise { + const response = await fetch(projectApiUrl(path, params), { + headers: { + Accept: 'application/json' + }, + signal: AbortSignal.timeout(projectUpdatesConfig.timeoutMs) + }); + + if (!response.ok) { + throw new Error(`Project updates source failed (${response.status})`); + } + + return response.json() as Promise; +} + +function mapProjectRepository(value: unknown): ProjectUpdatesRepository { + if (!isObjectRecord(value)) { + return fallbackProjectRepository(); + } + + return { + name: stringField(value, 'name') ?? projectUpdatesConfig.repo, + fullName: stringField(value, 'full_name') ?? `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`, + url: projectRepositoryUrl(), + defaultBranch: stringField(value, 'default_branch') ?? 'main', + updatedAt: normalizedDate(stringField(value, 'updated_at')) + }; +} + +function mapProjectCommit(value: unknown): ProjectUpdateCommit | null { + if (!isObjectRecord(value)) return null; + + const sha = stringField(value, 'sha'); + if (!sha) return null; + + const commit = objectField(value, 'commit'); + const commitAuthor = objectField(commit, 'author'); + const message = stringField(commit, 'message') ?? sha.slice(0, 7); + const fallback = sha.slice(0, 7); + const createdAt = + normalizedDate(stringField(value, 'created')) ?? + normalizedDate(stringField(commitAuthor, 'date')) ?? + normalizedDate(stringField(objectField(commit, 'committer'), 'date')); + + if (!createdAt) return null; + + return { + sha, + shortSha: sha.slice(0, 7), + title: projectCommitTitle(message, fallback), + message, + createdAt, + authorName: + stringField(commitAuthor, 'name') ?? + stringField(objectField(value, 'author'), 'login') ?? + projectUpdatesConfig.owner, + url: `${projectRepositoryUrl()}/commit/${sha}` + }; +} + +function mapProjectRelease(value: unknown): ProjectUpdateRelease | null { + if (!isObjectRecord(value)) return null; + + const tagName = stringField(value, 'tag_name'); + if (!tagName) return null; + + return { + tagName, + name: stringField(value, 'name') ?? tagName, + publishedAt: normalizedDate(stringField(value, 'published_at')) ?? normalizedDate(stringField(value, 'created_at')), + url: `${projectRepositoryUrl()}/releases/tag/${encodeURIComponent(tagName)}` + }; +} + +function logProjectUpdatesError(source: string, error: unknown): void { + app.log.warn({ err: error, source }, 'Project updates source unavailable'); +} + +async function getProjectCommitPage(query: Record): Promise { + const cursor = decodeProjectUpdatesCursor(projectUpdatesQueryValue(query.cursor)); + const limit = cursor?.limit ?? cleanProjectUpdatesLimit(query); + const page = cursor?.page ?? 1; + const value = await fetchProjectJson('/commits', { + page, + limit: limit + 1, + stat: false, + files: false, + verification: false + }); + const commits = Array.isArray(value) + ? value.map(mapProjectCommit).filter((commit): commit is ProjectUpdateCommit => commit !== null) + : []; + const hasMore = commits.length > limit; + + return { + items: commits.slice(0, limit), + nextCursor: hasMore ? encodeProjectUpdatesCursor(page + 1, limit) : null, + hasMore + }; +} + +async function getProjectUpdates(query: Record = {}): Promise { + const [repository, commits, releases] = await Promise.all([ + fetchProjectJson() + .then(mapProjectRepository) + .catch((error: unknown) => { + logProjectUpdatesError('repository', error); + return fallbackProjectRepository(); + }), + getProjectCommitPage(query).catch((error: unknown) => { + logProjectUpdatesError('commits', error); + throw error; + }), + fetchProjectJson('/releases', { limit: projectUpdatesConfig.releaseLimit, draft: false, 'pre-release': false }) + .then((value) => + Array.isArray(value) ? value.map(mapProjectRelease).filter((release): release is ProjectUpdateRelease => release !== null) : [] + ) + .catch((error: unknown) => { + logProjectUpdatesError('releases', error); + return []; + }) + ]); + + return { repository, commits, releases }; +} + function serverMessage( locale: string, key: @@ -844,6 +1125,10 @@ app.get('/api/system-wordings', async (request) => getSystemWordings(requestLoca app.get('/api/options', async (request) => getOptions(requestLocale(request))); +app.get('/api/project-updates', async (request) => + getProjectUpdates(request.query as Record) +); + app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); app.get('/api/users/:id/profile', async (request, reply) => { diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 548c870..63d3a58 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -9,6 +9,8 @@ export const iconCancel: AppIcon = 'mdi:close'; export const iconCheck: AppIcon = 'mdi:check'; export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline'; export const iconChevronDown: AppIcon = 'mdi:chevron-down'; +export const iconChevronRight: AppIcon = 'mdi:chevron-right'; +export const iconChevronUp: AppIcon = 'mdi:chevron-up'; export const iconClose: AppIcon = 'mdi:close'; export const iconComment: AppIcon = 'mdi:comment-outline'; export const iconCopy: AppIcon = 'mdi:content-copy'; @@ -19,6 +21,8 @@ export const iconDreamIsland: AppIcon = 'mdi:palm-tree'; export const iconEdit: AppIcon = 'mdi:pencil-outline'; export const iconError: AppIcon = 'mdi:close-circle-outline'; export const iconEvent: AppIcon = 'mdi:calendar-star'; +export const iconExternal: AppIcon = 'mdi:open-in-new'; +export const iconGitCommit: AppIcon = 'mdi:source-commit'; export const iconHabitat: AppIcon = 'mdi:pine-tree'; export const iconHome: AppIcon = 'mdi:home-variant-outline'; export const iconImage: AppIcon = 'mdi:image-outline'; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3d3f019..9205a4c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue'; import RecipeDetail from '../views/RecipeDetail.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue'; import LifeView from '../views/LifeView.vue'; +import ProjectUpdatesView from '../views/ProjectUpdatesView.vue'; import LegalView from '../views/LegalView.vue'; import ComingSoonView from '../views/ComingSoonView.vue'; import AdminView from '../views/AdminView.vue'; @@ -180,6 +181,17 @@ export const router = createRouter({ }, { path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } }, { path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } }, + { + path: '/project-updates', + component: ProjectUpdatesView, + meta: { + seo: seo({ + titleKey: 'pages.projectUpdates.title', + descriptionKey: 'pages.projectUpdates.subtitle', + canonicalPath: '/project-updates' + }) + } + }, { path: '/privacy-policy', component: LegalView, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index da1ee4e..9731e7c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -64,6 +64,48 @@ export interface UserSummary { displayName: string; } +export interface ProjectUpdatesRepository { + name: string; + fullName: string; + url: string; + defaultBranch: string; + updatedAt: string | null; +} + +export interface ProjectUpdateCommit { + sha: string; + shortSha: string; + title: string; + message: string; + createdAt: string; + authorName: string; + url: string; +} + +export interface ProjectUpdateRelease { + tagName: string; + name: string; + publishedAt: string | null; + url: string; +} + +export interface ProjectCommitPage { + items: ProjectUpdateCommit[]; + nextCursor: string | null; + hasMore: boolean; +} + +export interface ProjectUpdates { + repository: ProjectUpdatesRepository; + commits: ProjectCommitPage; + releases: ProjectUpdateRelease[]; +} + +export interface ProjectUpdatesParams { + cursor?: string | null; + limit?: number; +} + export interface EntityImage { path: string; url: string; @@ -836,6 +878,13 @@ async function deleteAndGetJson(path: string): Promise { export const api = { languages: () => getJson('/api/languages'), + projectUpdates: (params: ProjectUpdatesParams = {}) => + getJson( + `/api/project-updates${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), adminLanguages: () => getJson('/api/admin/languages'), createLanguage: (payload: Omit & { sortOrder?: number }) => sendJson('/api/admin/languages', 'POST', payload), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 87a1a96..ea4dc9a 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -4852,6 +4852,332 @@ button:disabled, align-self: end; } +.home-project-updates__panel { + display: grid; + gap: 16px; + padding: 16px; + border: 2px solid var(--line-strong); + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--shadow-control); +} + +.home-project-updates__repo { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + min-width: 0; +} + +.home-project-updates__repo-label, +.home-project-updates__updated { + color: var(--muted); + font-size: 13px; + font-weight: 850; +} + +.home-project-updates__repo a { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--pokemon-blue-deep); + font-weight: 950; + overflow-wrap: anywhere; +} + +.home-project-updates__repo a:hover { + color: var(--pokemon-blue); +} + +.home-project-updates__updated { + margin-left: auto; +} + +.home-project-updates__skeleton, +.home-project-updates__content, +.home-project-updates__group, +.home-project-updates__commit { + display: grid; +} + +.home-project-updates__skeleton, +.home-project-updates__content { + gap: 18px; +} + +.home-project-updates__skeleton { + padding: 8px 0; +} + +.home-project-updates__group { + gap: 10px; +} + +.home-project-updates__group h3 { + margin: 0; + color: var(--ink); + font-size: 16px; + font-weight: 950; +} + +.home-project-updates__list { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} + +.home-project-updates__item { + min-height: 78px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 14px 0; + border-top: 1px solid var(--line); +} + +.home-project-updates__item:first-child { + border-top: 0; +} + +.home-project-updates__commit { + min-width: 0; + gap: 8px; +} + +.home-project-updates__title { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 9px; +} + +.home-project-updates__title strong { + min-width: 0; + color: var(--ink); + font-weight: 950; + line-height: 1.28; + overflow-wrap: anywhere; +} + +.home-project-updates__sha { + flex: 0 0 auto; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--surface-soft); + color: var(--pokemon-blue-deep); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + font-weight: 850; + line-height: 1.35; +} + +.home-project-updates__meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.home-project-updates__link { + white-space: nowrap; +} + +.home-project-updates__actions { + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + padding-top: 4px; +} + +.project-updates-panel { + display: grid; + gap: 16px; + padding: 18px; + border: 2px solid var(--line-strong); + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--shadow-control); +} + +.project-updates-panel h2 { + margin: 0; + color: var(--ink); + font-family: var(--font-display); + font-size: 24px; + font-weight: 950; + line-height: 1.12; +} + +.project-updates-repo { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 14px; +} + +.project-updates-repo__icon { + width: 48px; + height: 48px; + display: grid; + place-items: center; + border: 2px solid var(--line-strong); + border-radius: var(--radius-control); + background: var(--pokemon-yellow); + box-shadow: 0 3px 0 var(--line-strong); + color: #172036; +} + +.project-updates-repo__copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.project-updates-repo__copy span, +.project-updates-repo__meta { + color: var(--muted); + font-size: 13px; + font-weight: 850; +} + +.project-updates-repo__copy a { + color: var(--pokemon-blue-deep); + font-weight: 950; + overflow-wrap: anywhere; +} + +.project-updates-repo__copy a:hover { + color: var(--pokemon-blue); +} + +.project-updates-list { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} + +.project-updates-list__item { + display: grid; + gap: 12px; + padding: 14px 0; + border-top: 1px solid var(--line); +} + +.project-updates-list__item:first-child { + border-top: 0; +} + +.project-updates-list__row, +.project-updates-list__item:not(.project-updates-list__item--commit) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 14px; +} + +.project-updates-list__main { + min-width: 0; + display: grid; + gap: 8px; +} + +.project-updates-list__title { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 9px; +} + +.project-updates-list__title strong { + min-width: 0; + color: var(--ink); + font-weight: 950; + line-height: 1.28; + overflow-wrap: anywhere; +} + +.project-updates-list__sha { + flex: 0 0 auto; + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--surface-soft); + color: var(--pokemon-blue-deep); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + font-weight: 850; + line-height: 1.35; +} + +.project-updates-list__meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.project-updates-list__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.project-updates-message { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.project-updates-message span { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.project-updates-message pre { + margin: 0; + color: var(--ink-soft); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.project-updates-more-skeleton { + display: grid; + gap: 10px; + padding: 8px 0 2px; +} + +.project-updates-sentinel { + min-height: 1px; +} + +.project-updates-actions { + display: flex; + justify-content: center; + padding-top: 4px; +} + .auth-page { display: grid; justify-items: center; @@ -5898,6 +6224,21 @@ button:disabled, grid-template-columns: repeat(2, minmax(0, 1fr)); } + .home-project-updates__updated { + margin-left: 0; + } + + .project-updates-repo, + .project-updates-list__row, + .project-updates-list__item:not(.project-updates-list__item--commit) { + grid-template-columns: 1fr; + align-items: start; + } + + .project-updates-list__actions { + justify-content: flex-start; + } + .appearance-row__main { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -5973,6 +6314,36 @@ button:disabled, grid-template-columns: 1fr; } + .home-project-updates__item, + .home-project-updates__title { + grid-template-columns: 1fr; + } + + .home-project-updates__item { + align-items: start; + } + + .home-project-updates__title { + display: grid; + } + + .home-project-updates__link { + width: 100%; + } + + .project-updates-panel { + padding: 16px; + } + + .project-updates-list__title { + display: grid; + } + + .project-updates-list__actions .ui-button, + .project-updates-list__item > .ui-button { + width: 100%; + } + .home-dex__screen { min-height: 420px; margin: 12px; diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 4d7299d..6189a6e 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,25 +1,34 @@