Migrate about pages to App Router (#432)

## Summary
- Migrated about, updates, and roadmap pages from Pages Router to App
Router
- Fixed profile page data loading and display
- Created API route handlers for proxying backend calls
- Fixed translation format issues with next-intl

## Changes
- Created new App Router pages under `/app/[locale]/`
- Fixed translation interpolation from `{{variable}}` to `{variable}`
format
- Added API routes for characters, raids, summons, and weapons
- Fixed infinite recursion in ChangelogUnit by renaming fetch function
- Converted from useTranslation to useTranslations hook

## Test plan
- [x] About page loads and displays correctly
- [x] Updates page fetches and displays changelog data
- [x] Roadmap page renders without errors
- [x] Profile page shows user teams correctly
- [x] All translations render properly

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-09-03 17:20:16 -07:00 committed by GitHub
parent fa23c13db1
commit 73395efee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 567 additions and 147 deletions

View file

@ -122,23 +122,33 @@ const ProfilePageClient: React.FC<Props> = ({
setFetching(true) setFetching(true)
try { try {
// Construct URL for fetching more data // Construct URL for fetching more data - using the users endpoint
const url = new URL(`/api/parties`, window.location.origin) const url = new URL(`${process.env.NEXT_PUBLIC_SIERO_API_URL}/users/${initialData.user.username}`, window.location.origin)
url.searchParams.set('username', initialData.user.username)
url.searchParams.set('page', (currentPage + 1).toString()) url.searchParams.set('page', (currentPage + 1).toString())
if (element) url.searchParams.set('element', element.toString()) if (element) url.searchParams.set('element', element.toString())
if (raid) url.searchParams.set('raid', raid) if (raid) url.searchParams.set('raid_id', raid)
if (recency) url.searchParams.set('recency', recency) if (recency) url.searchParams.set('recency', recency)
const response = await fetch(url.toString()) const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json'
}
})
const data = await response.json() const data = await response.json()
if (data.parties && Array.isArray(data.parties)) { // Extract parties from the profile response
setParties([...parties, ...data.parties]) const newParties = data.profile?.parties || []
setCurrentPage(data.pagination?.current_page || currentPage + 1)
setTotalPages(data.pagination?.total_pages || totalPages) if (newParties.length > 0) {
setRecordCount(data.pagination?.record_count || recordCount) setParties([...parties, ...newParties])
// Update pagination from meta
if (data.meta) {
setCurrentPage(currentPage + 1)
setTotalPages(data.meta.total_pages || totalPages)
setRecordCount(data.meta.count || recordCount)
}
} }
} catch (error) { } catch (error) {
console.error('Error loading more parties', error) console.error('Error loading more parties', error)
@ -222,12 +232,7 @@ const ProfilePageClient: React.FC<Props> = ({
raidGroups={initialData.raidGroups} raidGroups={initialData.raidGroups}
recency={recency} recency={recency}
> >
<UserInfo <UserInfo user={initialData.user} />
name={initialData.user.username}
picture={initialData.user.avatar.picture}
element={initialData.user.avatar.element}
gender={initialData.user.gender}
/>
</FilterBar> </FilterBar>
<section>{renderInfiniteScroll}</section> <section>{renderInfiniteScroll}</section>

View file

@ -58,7 +58,6 @@ export default async function ProfilePage({
notFound() notFound()
} }
// Prepare data for client component
const initialData = { const initialData = {
user: userData.user, user: userData.user,
teams: teamsData.results || [], teams: teamsData.results || [],

View file

@ -0,0 +1,99 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter, usePathname } from '~/i18n/navigation'
import { AboutTabs } from '~/utils/enums'
import AboutPage from '~/components/about/AboutPage'
import UpdatesPage from '~/components/about/UpdatesPage'
import RoadmapPage from '~/components/about/RoadmapPage'
import SegmentedControl from '~/components/common/SegmentedControl'
import Segment from '~/components/common/Segment'
export default function AboutPageClient() {
const t = useTranslations('common')
const router = useRouter()
const pathname = usePathname()
const [currentTab, setCurrentTab] = useState<AboutTabs>(AboutTabs.About)
useEffect(() => {
const parts = pathname.split('/')
const lastPart = parts[parts.length - 1]
switch (lastPart) {
case 'about':
setCurrentTab(AboutTabs.About)
break
case 'updates':
setCurrentTab(AboutTabs.Updates)
break
case 'roadmap':
setCurrentTab(AboutTabs.Roadmap)
break
default:
setCurrentTab(AboutTabs.About)
}
}, [pathname])
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value
router.push(`/${value}`)
switch (value) {
case 'about':
setCurrentTab(AboutTabs.About)
break
case 'updates':
setCurrentTab(AboutTabs.Updates)
break
case 'roadmap':
setCurrentTab(AboutTabs.Roadmap)
break
}
}
const currentSection = () => {
switch (currentTab) {
case AboutTabs.About:
return <AboutPage />
case AboutTabs.Updates:
return <UpdatesPage />
case AboutTabs.Roadmap:
return <RoadmapPage />
}
}
return (
<section>
<SegmentedControl blended={true}>
<Segment
groupName="about"
name="about"
selected={currentTab == AboutTabs.About}
onClick={handleTabClicked}
>
{t('about.segmented_control.about')}
</Segment>
<Segment
groupName="about"
name="updates"
selected={currentTab == AboutTabs.Updates}
onClick={handleTabClicked}
>
{t('about.segmented_control.updates')}
</Segment>
<Segment
groupName="about"
name="roadmap"
selected={currentTab == AboutTabs.Roadmap}
onClick={handleTabClicked}
>
{t('about.segmented_control.roadmap')}
</Segment>
</SegmentedControl>
{currentSection()}
</section>
)
}

View file

@ -0,0 +1,28 @@
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import AboutPageClient from './AboutPageClient'
export async function generateMetadata({
params: { locale }
}: {
params: { locale: string }
}): Promise<Metadata> {
const t = await getTranslations({ locale, namespace: 'common' })
return {
title: t('page.titles.about'),
description: t('page.descriptions.about')
}
}
export default async function AboutPage({
params: { locale }
}: {
params: { locale: string }
}) {
return (
<div id="About">
<AboutPageClient />
</div>
)
}

View file

@ -0,0 +1,66 @@
'use client'
import React, { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from '~/i18n/navigation'
import { AboutTabs } from '~/utils/enums'
import AboutPage from '~/components/about/AboutPage'
import UpdatesPage from '~/components/about/UpdatesPage'
import RoadmapPage from '~/components/about/RoadmapPage'
import SegmentedControl from '~/components/common/SegmentedControl'
import Segment from '~/components/common/Segment'
export default function RoadmapPageClient() {
const t = useTranslations('common')
const router = useRouter()
const [currentTab] = useState<AboutTabs>(AboutTabs.Roadmap)
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value
router.push(`/${value}`)
}
const currentSection = () => {
switch (currentTab) {
case AboutTabs.About:
return <AboutPage />
case AboutTabs.Updates:
return <UpdatesPage />
case AboutTabs.Roadmap:
return <RoadmapPage />
}
}
return (
<section>
<SegmentedControl blended={true}>
<Segment
groupName="about"
name="about"
selected={currentTab == AboutTabs.About}
onClick={handleTabClicked}
>
{t('about.segmented_control.about')}
</Segment>
<Segment
groupName="about"
name="updates"
selected={currentTab == AboutTabs.Updates}
onClick={handleTabClicked}
>
{t('about.segmented_control.updates')}
</Segment>
<Segment
groupName="about"
name="roadmap"
selected={currentTab == AboutTabs.Roadmap}
onClick={handleTabClicked}
>
{t('about.segmented_control.roadmap')}
</Segment>
</SegmentedControl>
{currentSection()}
</section>
)
}

View file

@ -0,0 +1,28 @@
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import RoadmapPageClient from './RoadmapPageClient'
export async function generateMetadata({
params: { locale }
}: {
params: { locale: string }
}): Promise<Metadata> {
const t = await getTranslations({ locale, namespace: 'common' })
return {
title: t('page.titles.roadmap'),
description: t('page.descriptions.roadmap')
}
}
export default async function RoadmapPage({
params: { locale }
}: {
params: { locale: string }
}) {
return (
<div id="About">
<RoadmapPageClient />
</div>
)
}

View file

@ -0,0 +1,66 @@
'use client'
import React, { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from '~/i18n/navigation'
import { AboutTabs } from '~/utils/enums'
import AboutPage from '~/components/about/AboutPage'
import UpdatesPage from '~/components/about/UpdatesPage'
import RoadmapPage from '~/components/about/RoadmapPage'
import SegmentedControl from '~/components/common/SegmentedControl'
import Segment from '~/components/common/Segment'
export default function UpdatesPageClient() {
const t = useTranslations('common')
const router = useRouter()
const [currentTab] = useState<AboutTabs>(AboutTabs.Updates)
function handleTabClicked(event: React.ChangeEvent<HTMLInputElement>) {
const value = event.target.value
router.push(`/${value}`)
}
const currentSection = () => {
switch (currentTab) {
case AboutTabs.About:
return <AboutPage />
case AboutTabs.Updates:
return <UpdatesPage />
case AboutTabs.Roadmap:
return <RoadmapPage />
}
}
return (
<section>
<SegmentedControl blended={true}>
<Segment
groupName="about"
name="about"
selected={currentTab == AboutTabs.About}
onClick={handleTabClicked}
>
{t('about.segmented_control.about')}
</Segment>
<Segment
groupName="about"
name="updates"
selected={currentTab == AboutTabs.Updates}
onClick={handleTabClicked}
>
{t('about.segmented_control.updates')}
</Segment>
<Segment
groupName="about"
name="roadmap"
selected={currentTab == AboutTabs.Roadmap}
onClick={handleTabClicked}
>
{t('about.segmented_control.roadmap')}
</Segment>
</SegmentedControl>
{currentSection()}
</section>
)
}

View file

@ -0,0 +1,28 @@
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import UpdatesPageClient from './UpdatesPageClient'
export async function generateMetadata({
params: { locale }
}: {
params: { locale: string }
}): Promise<Metadata> {
const t = await getTranslations({ locale, namespace: 'common' })
return {
title: t('page.titles.updates'),
description: t('page.descriptions.updates')
}
}
export default async function UpdatesPage({
params: { locale }
}: {
params: { locale: string }
}) {
return (
<div id="About">
<UpdatesPageClient />
</div>
)
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchFromApi } from '~/app/lib/api-utils';
// GET handler for fetching a single character
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json(
{ error: 'Character ID is required' },
{ status: 400 }
);
}
const data = await fetchFromApi(`/characters/${id}`);
return NextResponse.json(data);
} catch (error: any) {
console.error(`Error fetching character ${params.id}`, error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch character' },
{ status: error.response?.status || 500 }
);
}
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchFromApi } from '~/app/lib/api-utils';
// GET handler for fetching a single raid
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json(
{ error: 'Raid ID is required' },
{ status: 400 }
);
}
const data = await fetchFromApi(`/raids/${id}`);
return NextResponse.json(data);
} catch (error: any) {
console.error(`Error fetching raid ${params.id}`, error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch raid' },
{ status: error.response?.status || 500 }
);
}
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchFromApi } from '~/app/lib/api-utils';
// GET handler for fetching a single summon
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json(
{ error: 'Summon ID is required' },
{ status: 400 }
);
}
const data = await fetchFromApi(`/summons/${id}`);
return NextResponse.json(data);
} catch (error: any) {
console.error(`Error fetching summon ${params.id}`, error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch summon' },
{ status: error.response?.status || 500 }
);
}
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchFromApi } from '~/app/lib/api-utils';
// GET handler for fetching a single weapon
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
if (!id) {
return NextResponse.json(
{ error: 'Weapon ID is required' },
{ status: 400 }
);
}
const data = await fetchFromApi(`/weapons/${id}`);
return NextResponse.json(data);
} catch (error: any) {
console.error(`Error fetching weapon ${params.id}`, error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch weapon' },
{ status: error.response?.status || 500 }
);
}
}

View file

@ -22,19 +22,17 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<h1>{common('about.segmented_control.about')}</h1> <h1>{common('about.segmented_control.about')}</h1>
<section> <section>
<h2> <h2>
{/* TODO: Refactor to about.rich() */} {about.rich('about.subtitle', {
{about("about.subtitle")} gameLink: (chunks) => (
{/* <Trans i18nKey="about:about.subtitle"> <a
Granblue.team is a tool to save and share team compositions for{' '} href="https://game.granbluefantasy.jp"
<a target="_blank"
href="https://game.granbluefantasy.jp" rel="noreferrer"
target="_blank" >
rel="noreferrer" {chunks}
> </a>
Granblue Fantasy )
</a> })}
, a social RPG from Cygames.
</Trans> */}
</h2> </h2>
<p>{about('about.explanation.0')}</p> <p>{about('about.explanation.0')}</p>
<p>{about('about.explanation.1')}</p> <p>{about('about.explanation.1')}</p>
@ -56,59 +54,52 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<section> <section>
<h2>{about('about.credits.title')}</h2> <h2>{about('about.credits.title')}</h2>
<p> <p>
{/* TODO: Refactor to about.rich() */} {about.rich('about.credits.maintainer', {
{about('about.credits.maintainer')} link: (chunks) => (
{/* <Trans i18nKey="about:about.credits.maintainer"> <a
Granblue.team was built and is maintained by{' '} href="https://twitter.com/jedmund"
<a target="_blank"
href="https://twitter.com/jedmund" rel="noreferrer"
target="_blank" >
rel="noreferrer" {chunks}
> </a>
@jedmund )
</a> })}
.
</Trans> */}
</p> </p>
<p> <p>
{/* TODO: Refactor to about.rich() */} {about.rich('about.credits.assistance', {
{about('about.credits.assistance')} link1: (chunks) => (
{/* <Trans i18nKey="about:about.credits.assistance"> <a
Many thanks to{' '} href="https://twitter.com/lalalalinna"
<a target="_blank"
href="https://twitter.com/lalalalinna" rel="noreferrer"
target="_blank" >
rel="noreferrer" {chunks}
> </a>
@lalalalinna ),
</a>{' '} link2: (chunks) => (
and{' '} <a
<a href="https://twitter.com/tarngerine"
href="https://twitter.com/tarngerine" target="_blank"
target="_blank" rel="noreferrer"
rel="noreferrer" >
> {chunks}
@tarngerine </a>
</a> )
, who both provided a lot of help and advice as I was ramping up. })}
</Trans> */}
</p> </p>
<p> <p>
{/* TODO: Refactor to about.rich() */} {about.rich('about.credits.support', {
{about('about.credits.support')} link: (chunks) => (
{/* <Trans i18nKey="about:about.credits.support"> <a
Many thanks also go to everyone in{' '} href="https://game.granbluefantasy.jp/#guild/detail/1190185"
<a target="_blank"
href="https://game.granbluefantasy.jp/#guild/detail/1190185" rel="noreferrer"
target="_blank" >
rel="noreferrer" {chunks}
> </a>
Fireplace )
</a>{' '} })}
and the granblue-tools Discord for all of their help with with bug
testing, feature requests, and moral support. (P.S. We&apos;re
recruiting!)
</Trans> */}
</p> </p>
</section> </section>
@ -134,19 +125,17 @@ const AboutPage: React.FC<Props> = (props: Props) => {
<section> <section>
<h2>{about('about.license.title')}</h2> <h2>{about('about.license.title')}</h2>
<p> <p>
{/* TODO: Refactor to about.rich() */} {about.rich('about.license.license', {
{about('about.license.license')} link: (chunks) => (
{/* <Trans i18nKey="about:about.license.license"> <a
This app is licensed under{' '} href="https://choosealicense.com/licenses/agpl-3.0/"
<a target="_blank"
href="https://choosealicense.com/licenses/agpl-3.0/" rel="noreferrer"
target="_blank" >
rel="noreferrer" {chunks}
> </a>
GNU AGPLv3 )
</a> })}
.
</Trans> */}
</p> </p>
<p>{about('about.license.explanation')}</p> <p>{about('about.license.explanation')}</p>
</section> </section>

View file

@ -1,7 +1,6 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getCookie } from 'cookies-next' import { getCookie } from 'cookies-next'
import api from '~utils/api'
import styles from './index.module.scss' import styles from './index.module.scss'
@ -28,49 +27,41 @@ const ChangelogUnit = ({ id, type, image }: Props) => {
// Hooks // Hooks
useEffect(() => { useEffect(() => {
fetch() fetchItem()
}, []) }, [id, type])
async function fetch() { async function fetchItem() {
switch (type) { try {
case 'character': let endpoint = ''
const character = await fetchCharacter()
setItem(character.data) switch (type) {
break case 'character':
endpoint = `/api/characters/${id}`
case 'weapon': break
const weapon = await fetchWeapon() case 'weapon':
setItem(weapon.data) endpoint = `/api/weapons/${id}`
break break
case 'summon':
case 'summon': endpoint = `/api/summons/${id}`
const summon = await fetchSummon() break
setItem(summon.data) case 'raid':
break endpoint = `/api/raids/${id}`
break
case 'raid': default:
const raid = await fetchRaid() return
setItem(raid.data) }
break
const response = await fetch(endpoint)
if (response.ok) {
const data = await response.json()
setItem(data)
}
} catch (error) {
console.error(`Error fetching ${type} ${id}:`, error)
} }
} }
async function fetchCharacter() {
return api.endpoints.characters.getOne({ id: id })
}
async function fetchWeapon() {
return api.endpoints.weapons.getOne({ id: id })
}
async function fetchSummon() {
return api.endpoints.summons.getOne({ id: id })
}
async function fetchRaid() {
return api.endpoints.raids.getOne({ id: id })
}
const imageUrl = () => { const imageUrl = () => {
let src = '' let src = ''

View file

@ -1,3 +1,5 @@
'use client'
import React from 'react' import React from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@ -11,8 +13,8 @@ import styles from './index.module.scss'
const ROADMAP_ITEMS = 6 const ROADMAP_ITEMS = 6
const RoadmapPage = () => { const RoadmapPage = () => {
const { t: common } = useTranslation('common') const common = useTranslations('common')
const { t: about } = useTranslation('about') const about = useTranslations('about')
const classes = classNames(styles.roadmap, 'PageContent') const classes = classNames(styles.roadmap, 'PageContent')

View file

@ -14,7 +14,8 @@ const UpdatesPage = () => {
const classes = classNames(styles.updates, 'PageContent') const classes = classNames(styles.updates, 'PageContent')
const [activeYear, setActiveYear] = useState(new Date().getFullYear()) // Default to most recent year with content (2024)
const [activeYear, setActiveYear] = useState(2024)
const getYearButtonClass = (year: number) => const getYearButtonClass = (year: number) =>
classNames({ classNames({
[styles.yearButton]: true, [styles.yearButton]: true,

View file

@ -5,7 +5,7 @@
} }
}, },
"about": { "about": {
"subtitle": "Granblue.team is a tool to save and share team compositions for <2>Granblue Fantasy</2>, a social RPG from Cygames.", "subtitle": "Granblue.team is a tool to save and share team compositions for <gameLink>Granblue Fantasy</gameLink>, a social RPG from Cygames.",
"explanation": [ "explanation": [
"To get started, all you have to do is add an item to a team and a URL will be created for you to share wherever you like, no account needed.", "To get started, all you have to do is add an item to a team and a URL will be created for you to share wherever you like, no account needed.",
"If you do make an account, you can save any teams you find for future reference and keep all of your teams together in one place." "If you do make an account, you can save any teams you find for future reference and keep all of your teams together in one place."
@ -17,9 +17,9 @@
}, },
"credits": { "credits": {
"title": "Credits", "title": "Credits",
"maintainer": "Granblue.team was built and is maintained by <2>@jedmund</2>.", "maintainer": "Granblue.team was built and is maintained by <link>@jedmund</link>.",
"assistance": "Many thanks to <2>@lalalalinna</2> and <6>@tarngerine</6>, who both provided a lot of help and advice as I was ramping up.", "assistance": "Many thanks to <link1>@lalalalinna</link1> and <link2>@tarngerine</link2>, who both provided a lot of help and advice as I was ramping up.",
"support": "Many thanks also go to everyone in <2>Fireplace</2> and the granblue-tools Discord for all of their help with with bug testing, feature requests, and moral support. (P.S. We're recruiting!)" "support": "Many thanks also go to everyone in <link>Fireplace</link> and the granblue-tools Discord for all of their help with with bug testing, feature requests, and moral support. (P.S. We're recruiting!)"
}, },
"contributing": { "contributing": {
"title": "Contributing", "title": "Contributing",
@ -27,7 +27,7 @@
}, },
"license": { "license": {
"title": "License", "title": "License",
"license": "This app is licensed under <2>GNU AGPLv3</2>.", "license": "This app is licensed under <link>GNU AGPLv3</link>.",
"explanation": "Plainly, that means you can download the source, modify it, and redistribute it as long as you attribute this project, use the same license, and keep your derivative work open source as well." "explanation": "Plainly, that means you can download the source, modify it, and redistribute it as long as you attribute this project, use the same license, and keep your derivative work open source as well."
}, },
"copyright": { "copyright": {

View file

@ -1,4 +1,5 @@
{ {
"noUpdates": "No updates available for this year",
"labels": { "labels": {
"characters": "New characters", "characters": "New characters",
"weapons": "New weapons", "weapons": "New weapons",
@ -22,7 +23,7 @@
"updates": "Other updates" "updates": "Other updates"
}, },
"events": { "events": {
"date": "{{month}}/{{year}}", "date": "{month}/{year}",
"legfest": "Legend Festival", "legfest": "Legend Festival",
"flash": "Flash Gala", "flash": "Flash Gala",
"content": "Content Update", "content": "Content Update",

View file

@ -5,7 +5,7 @@
} }
}, },
"about": { "about": {
"subtitle": "Granblue.teamは<2>グランブルーファンタジー</2>の編成を作成・保存・共有するサイトです。", "subtitle": "Granblue.teamは<gameLink>グランブルーファンタジー</gameLink>の編成を作成・保存・共有するサイトです。",
"explanation": [ "explanation": [
"新しい編成にキャラクター・武器・召喚石を追加するだけで、好きな場所で共有できるURLが作成されます—アカウントなしで", "新しい編成にキャラクター・武器・召喚石を追加するだけで、好きな場所で共有できるURLが作成されます—アカウントなしで",
"しかしアカウントを作れば、見つけた編成を保存して今後の参考の参考にすることができますし、すべての編成を1つの場所にまとめておくことができます。" "しかしアカウントを作れば、見つけた編成を保存して今後の参考の参考にすることができますし、すべての編成を1つの場所にまとめておくことができます。"
@ -17,9 +17,9 @@
}, },
"credits": { "credits": {
"title": "謝意", "title": "謝意",
"maintainer": "Granblue.teamの創造者・維持者は<2>@jedmund</2>.", "maintainer": "Granblue.teamの創造者・維持者は<link>@jedmund</link>.",
"assistance": "<2>@lalalalinna</2>さんと<6>@tarngerine</6>さんがプロジェクト開始の時にたくさん助かりました。", "assistance": "<link1>@lalalalinna</link1>さんと<link2>@tarngerine</link2>さんがプロジェクト開始の時にたくさん助かりました。",
"support": "<2>Fireplace</2>団アナザーver)とgranblue-toolsのDiscordの皆さんの協力も感謝しています。(募集中です!)" "support": "<link>Fireplace</link>団アナザーver)とgranblue-toolsのDiscordの皆さんの協力も感謝しています。(募集中です!)"
}, },
"contributing": { "contributing": {
"title": "協力", "title": "協力",
@ -27,7 +27,7 @@
}, },
"license": { "license": {
"title": "ライセンス", "title": "ライセンス",
"license": "このサイトは<2>GNU AGPLv3</2>のライセンスで提供されています.", "license": "このサイトは<link>GNU AGPLv3</link>のライセンスで提供されています.",
"explanation": "派生のプロジェクトを作成したら、このプロジェクトをちゃんとリンクしてリンクし・派生のプロジェクトもオープンソースで同じライセンスで提供されたら、自由にソースコードをダウンロド・改変・再配布を許可されています。" "explanation": "派生のプロジェクトを作成したら、このプロジェクトをちゃんとリンクしてリンクし・派生のプロジェクトもオープンソースで同じライセンスで提供されたら、自由にソースコードをダウンロド・改変・再配布を許可されています。"
}, },
"copyright": { "copyright": {

View file

@ -1,4 +1,5 @@
{ {
"noUpdates": "この年のアップデートはありません",
"labels": { "labels": {
"characters": "新キャラクター", "characters": "新キャラクター",
"weapons": "新武器", "weapons": "新武器",
@ -22,7 +23,7 @@
"updates": "その他の更新" "updates": "その他の更新"
}, },
"events": { "events": {
"date": "{{year}}年{{month}}月", "date": "{year}年{month}月",
"legfest": "レジェンドフェス", "legfest": "レジェンドフェス",
"flash": "グランデフェス", "flash": "グランデフェス",
"content": "アップデート", "content": "アップデート",