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
This commit is contained in:
2026-05-03 23:40:34 +08:00
parent a0e07f101a
commit 8dfd03f3d2
9 changed files with 1207 additions and 2 deletions

View File

@@ -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';

View File

@@ -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,

View File

@@ -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<T>(path: string): Promise<T> {
export const api = {
languages: () => getJson<Language[]>('/api/languages'),
projectUpdates: (params: ProjectUpdatesParams = {}) =>
getJson<ProjectUpdates>(
`/api/project-updates${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
sendJson<Language[]>('/api/admin/languages', 'POST', payload),

View File

@@ -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;

View File

@@ -1,25 +1,34 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconAutomation,
iconChevronRight,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconExternal,
iconGitCommit,
iconHabitat,
iconItem,
iconLife,
iconPokemon,
iconRecipe
} from '../icons';
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
const { t } = useI18n();
const { t, locale } = useI18n();
const projectCommitPageSize = 5;
const projectUpdates = ref<ProjectUpdates | null>(null);
const projectUpdatesLoading = ref(true);
const projectCommits = ref<ProjectUpdateCommit[]>([]);
const primarySections = computed(() => [
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
@@ -42,6 +51,17 @@ const futureSections = computed(() => [
{ key: 'clothes', to: '/clothes', icon: iconClothes }
]);
const latestReleases = computed(() => projectUpdates.value?.releases.slice(0, 3) ?? []);
const showProjectUpdates = computed(
() => projectUpdatesLoading.value || projectCommits.value.length > 0 || latestReleases.value.length > 0
);
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
onMounted(() => {
void loadProjectUpdates();
});
function sectionTitleKey(key: string) {
return `pages.home.sections.${key}.title`;
}
@@ -49,6 +69,34 @@ function sectionTitleKey(key: string) {
function sectionDescriptionKey(key: string) {
return `pages.home.sections.${key}.description`;
}
async function loadProjectUpdates(): Promise<void> {
projectUpdatesLoading.value = true;
try {
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
projectUpdates.value = updates;
projectCommits.value = updates.commits.items;
} catch {
projectUpdates.value = null;
projectCommits.value = [];
} finally {
projectUpdatesLoading.value = false;
}
}
function formatDateTime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
</script>
<template>
@@ -145,6 +193,84 @@ function sectionDescriptionKey(key: string) {
</div>
</section>
<section v-if="showProjectUpdates" class="home-section home-project-updates" aria-labelledby="home-project-updates-title">
<div class="home-section__header">
<span class="page-kicker">{{ t('pages.home.projectUpdatesKicker') }}</span>
<h2 id="home-project-updates-title">{{ t('pages.home.projectUpdatesTitle') }}</h2>
</div>
<div class="home-project-updates__panel">
<div v-if="projectUpdates" class="home-project-updates__repo">
<span class="home-project-updates__repo-label">{{ t('pages.home.projectUpdatesRepo') }}</span>
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
<Icon :icon="iconGitCommit" class="ui-icon" aria-hidden="true" />
{{ projectUpdates.repository.fullName }}
</a>
<span v-if="repositoryUpdatedAt" class="home-project-updates__updated">
{{ t('pages.home.projectUpdatesUpdatedAt', { date: repositoryUpdatedAt }) }}
</span>
</div>
<div v-if="projectUpdatesLoading" class="home-project-updates__skeleton">
<Skeleton width="42%" />
<Skeleton width="76%" />
<Skeleton width="64%" />
</div>
<div v-else-if="projectUpdates" class="home-project-updates__content">
<div v-if="latestReleases.length" class="home-project-updates__group">
<h3>{{ t('pages.home.projectUpdatesReleases') }}</h3>
<ol class="home-project-updates__list">
<li v-for="release in latestReleases" :key="release.tagName" class="home-project-updates__item">
<div class="home-project-updates__commit">
<div class="home-project-updates__title">
<span class="home-project-updates__sha">{{ release.tagName }}</span>
<strong>{{ release.name }}</strong>
</div>
<div v-if="release.publishedAt" class="home-project-updates__meta">
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
</div>
</div>
<a class="ui-button ui-button--ghost home-project-updates__link" :href="release.url" target="_blank" rel="noreferrer">
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
{{ t('pages.home.projectUpdatesViewRelease') }}
</a>
</li>
</ol>
</div>
<div v-if="projectCommits.length" class="home-project-updates__group">
<h3>{{ t('pages.home.projectUpdatesCommits') }}</h3>
<ol class="home-project-updates__list">
<li v-for="commit in projectCommits" :key="commit.sha" class="home-project-updates__item">
<div class="home-project-updates__commit">
<div class="home-project-updates__title">
<span class="home-project-updates__sha">{{ commit.shortSha }}</span>
<strong>{{ commit.title }}</strong>
</div>
<div class="home-project-updates__meta">
<span>{{ commit.authorName }}</span>
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
</div>
</div>
<a class="ui-button ui-button--ghost home-project-updates__link" :href="commit.url" target="_blank" rel="noreferrer">
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
{{ t('pages.home.projectUpdatesViewCommit') }}
</a>
</li>
</ol>
<div v-if="showProjectUpdatesViewAll" class="home-project-updates__actions">
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/project-updates">
<Icon :icon="iconChevronRight" class="ui-icon" aria-hidden="true" />
{{ t('pages.home.projectUpdatesViewAll') }}
</RouterLink>
</div>
</div>
</div>
</div>
</section>
<section class="home-section" aria-labelledby="home-future-title">
<div class="home-section__header">
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import {
iconChevronDown,
iconChevronUp,
iconExternal,
iconGitCommit,
iconWarning
} from '../icons';
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
const { t, locale } = useI18n();
const projectCommitPageSize = 10;
const projectUpdates = ref<ProjectUpdates | null>(null);
const projectCommits = ref<ProjectUpdateCommit[]>([]);
const projectCommitCursor = ref<string | null>(null);
const projectHasMoreCommits = ref(false);
const loading = ref(true);
const loadingMore = ref(false);
const loadError = ref(false);
const loadMorePaused = ref(false);
const loadMoreSentinel = ref<HTMLElement | null>(null);
const expandedCommitShas = ref<Set<string>>(new Set());
let projectUpdatesObserver: IntersectionObserver | null = null;
const releases = computed(() => projectUpdates.value?.releases ?? []);
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
onMounted(() => {
void loadProjectUpdates();
});
onBeforeUnmount(() => {
disconnectProjectUpdatesObserver();
});
async function loadProjectUpdates(): Promise<void> {
loading.value = true;
loadingMore.value = false;
loadError.value = false;
loadMorePaused.value = false;
projectCommitCursor.value = null;
projectHasMoreCommits.value = false;
expandedCommitShas.value = new Set();
try {
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
projectUpdates.value = updates;
projectCommits.value = updates.commits.items;
projectCommitCursor.value = updates.commits.nextCursor;
projectHasMoreCommits.value = updates.commits.hasMore;
} catch {
projectUpdates.value = null;
projectCommits.value = [];
loadError.value = true;
} finally {
loading.value = false;
}
}
async function loadMoreProjectUpdates(): Promise<void> {
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value) {
return;
}
const cursor = projectCommitCursor.value;
if (!cursor) {
projectHasMoreCommits.value = false;
return;
}
loadingMore.value = true;
try {
const updates = await api.projectUpdates({
cursor,
limit: projectCommitPageSize
});
projectUpdates.value = updates;
const existingShas = new Set(projectCommits.value.map((commit) => commit.sha));
projectCommits.value = [...projectCommits.value, ...updates.commits.items.filter((commit) => !existingShas.has(commit.sha))];
projectCommitCursor.value = updates.commits.nextCursor;
projectHasMoreCommits.value = updates.commits.hasMore;
} catch {
loadMorePaused.value = true;
} finally {
loadingMore.value = false;
}
}
function retryLoadMore(): void {
loadMorePaused.value = false;
void loadMoreProjectUpdates();
}
function toggleCommitMessage(sha: string): void {
const nextExpanded = new Set(expandedCommitShas.value);
if (nextExpanded.has(sha)) {
nextExpanded.delete(sha);
} else {
nextExpanded.add(sha);
}
expandedCommitShas.value = nextExpanded;
}
function isCommitExpanded(sha: string): boolean {
return expandedCommitShas.value.has(sha);
}
function disconnectProjectUpdatesObserver(): void {
projectUpdatesObserver?.disconnect();
projectUpdatesObserver = null;
}
function observeProjectUpdatesLoadMore(): void {
disconnectProjectUpdatesObserver();
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value || !loadMoreSentinel.value) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
void loadMoreProjectUpdates();
return;
}
projectUpdatesObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
void loadMoreProjectUpdates();
}
},
{ rootMargin: '360px 0px' }
);
projectUpdatesObserver.observe(loadMoreSentinel.value);
}
function formatDateTime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
watch([loadMoreSentinel, projectHasMoreCommits, loading, loadingMore, loadMorePaused], observeProjectUpdatesLoadMore, {
flush: 'post'
});
</script>
<template>
<section class="page-stack project-updates-page">
<PageHeader :title="t('pages.projectUpdates.title')" :subtitle="t('pages.projectUpdates.subtitle')">
<template #kicker>{{ t('pages.projectUpdates.kicker') }}</template>
<template v-if="projectUpdates" #actions>
<a class="ui-button ui-button--ghost" :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
{{ t('pages.projectUpdates.openRepository') }}
</a>
</template>
</PageHeader>
<section v-if="loading" class="project-updates-panel" aria-busy="true" :aria-label="t('pages.projectUpdates.loading')">
<Skeleton width="40%" height="22px" />
<Skeleton width="76%" />
<Skeleton width="64%" />
<Skeleton variant="box" height="78px" />
<Skeleton variant="box" height="78px" />
<Skeleton variant="box" height="78px" />
</section>
<section v-else-if="loadError" class="project-updates-panel">
<p class="status">{{ t('errors.loadFailed') }}</p>
<button class="ui-button ui-button--ghost" type="button" @click="loadProjectUpdates">
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
{{ t('pages.projectUpdates.retry') }}
</button>
</section>
<template v-else-if="projectUpdates">
<section class="project-updates-panel">
<div class="project-updates-repo">
<span class="project-updates-repo__icon" aria-hidden="true">
<Icon :icon="iconGitCommit" class="ui-icon" />
</span>
<div class="project-updates-repo__copy">
<span>{{ t('pages.projectUpdates.sourceRepository') }}</span>
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
{{ projectUpdates.repository.fullName }}
</a>
</div>
<span v-if="repositoryUpdatedAt" class="project-updates-repo__meta">
{{ t('pages.projectUpdates.updatedAt', { date: repositoryUpdatedAt }) }}
</span>
</div>
</section>
<section v-if="releases.length" class="project-updates-panel">
<h2>{{ t('pages.projectUpdates.releases') }}</h2>
<ol class="project-updates-list">
<li v-for="release in releases" :key="release.tagName" class="project-updates-list__item">
<div class="project-updates-list__main">
<div class="project-updates-list__title">
<span class="project-updates-list__sha">{{ release.tagName }}</span>
<strong>{{ release.name }}</strong>
</div>
<div v-if="release.publishedAt" class="project-updates-list__meta">
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
</div>
</div>
<a class="ui-button ui-button--ghost ui-button--small" :href="release.url" target="_blank" rel="noreferrer">
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
{{ t('pages.projectUpdates.viewRelease') }}
</a>
</li>
</ol>
</section>
<section class="project-updates-panel">
<h2>{{ t('pages.projectUpdates.commits') }}</h2>
<ol v-if="projectCommits.length" class="project-updates-list">
<li v-for="commit in projectCommits" :key="commit.sha" class="project-updates-list__item project-updates-list__item--commit">
<div class="project-updates-list__row">
<div class="project-updates-list__main">
<div class="project-updates-list__title">
<span class="project-updates-list__sha">{{ commit.shortSha }}</span>
<strong>{{ commit.title }}</strong>
</div>
<div class="project-updates-list__meta">
<span>{{ commit.authorName }}</span>
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
</div>
</div>
<div class="project-updates-list__actions">
<button
class="ui-button ui-button--ghost ui-button--small"
type="button"
:aria-expanded="isCommitExpanded(commit.sha)"
@click="toggleCommitMessage(commit.sha)"
>
<Icon :icon="isCommitExpanded(commit.sha) ? iconChevronUp : iconChevronDown" class="ui-icon" aria-hidden="true" />
{{ isCommitExpanded(commit.sha) ? t('pages.projectUpdates.collapseMessage') : t('pages.projectUpdates.expandMessage') }}
</button>
<a class="ui-button ui-button--ghost ui-button--small" :href="commit.url" target="_blank" rel="noreferrer">
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
{{ t('pages.projectUpdates.viewCommit') }}
</a>
</div>
</div>
<div v-if="isCommitExpanded(commit.sha)" class="project-updates-message">
<span>{{ t('pages.projectUpdates.commitMessage') }}</span>
<pre>{{ commit.message }}</pre>
</div>
</li>
</ol>
<p v-else class="meta-line">{{ t('pages.projectUpdates.empty') }}</p>
<div v-if="loadingMore" class="project-updates-more-skeleton">
<Skeleton width="82%" />
<Skeleton width="58%" />
</div>
<div v-if="projectHasMoreCommits" ref="loadMoreSentinel" class="project-updates-sentinel" aria-hidden="true"></div>
<div v-if="loadMorePaused && projectHasMoreCommits" class="project-updates-actions">
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="retryLoadMore">
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
{{ t('pages.projectUpdates.retry') }}
</button>
</div>
</section>
</template>
</section>
</template>