This commit is contained in:
hfcRed 2026-03-21 02:36:44 +01:00
commit a8e36f8518
54 changed files with 398 additions and 105 deletions

View File

@ -173,7 +173,6 @@
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
padding-block-start: env(safe-area-inset-top);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
border-top: 1.5px solid var(--color-border);
@ -181,8 +180,9 @@
color: var(--color-text-high);
min-height: var(--layout-nav-height);
&:has(+ nav) {
&:first-child {
border-top: none;
padding-block-start: env(safe-area-inset-top);
}
}

View File

@ -582,8 +582,7 @@ function ChatPanel({
>
<Modal
className={clsx(
styles.panel,
"scrollbar",
styles.menuOverlay,
skipAnimation && styles.noAnimation,
)}
>

View File

@ -2,6 +2,7 @@
display: flex;
flex-direction: column;
height: 100%;
max-height: var(--visual-viewport-height);
overflow: hidden;
}

View File

@ -1,4 +1,5 @@
import clsx from "clsx";
import type { TFunction } from "i18next";
import { Search } from "lucide-react";
import * as React from "react";
import {
@ -18,6 +19,7 @@ import { Avatar } from "~/components/Avatar";
import { Image } from "~/components/Image";
import { Input } from "~/components/Input";
import type { SearchLoaderData } from "~/features/search/routes/search";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
mySlugify,
navIconUrl,
@ -83,6 +85,7 @@ export function GlobalSearch() {
const searchParamOpen = searchParams.get("search") === "open";
const searchParamType = searchParams.get("type");
const searchParamWeapon = searchParams.get("weapon");
const initialSearchType =
searchParamType && SEARCH_TYPES.includes(searchParamType as SearchType)
? (searchParamType as SearchType)
@ -91,10 +94,10 @@ export function GlobalSearch() {
const [isOpen, setIsOpen] = React.useState(searchParamOpen);
React.useEffect(() => {
if (searchParamOpen && !isOpen) {
if (searchParamOpen) {
setIsOpen(true);
}
}, [searchParamOpen, isOpen]);
}, [searchParamOpen]);
React.useEffect(() => {
setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.userAgent));
@ -115,10 +118,11 @@ export function GlobalSearch() {
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open && (searchParamOpen || searchParamType)) {
if (!open && (searchParamOpen || searchParamType || searchParamWeapon)) {
const newParams = new URLSearchParams(searchParams);
newParams.delete("search");
newParams.delete("type");
newParams.delete("weapon");
setSearchParams(newParams, { replace: true });
}
};
@ -138,8 +142,9 @@ export function GlobalSearch() {
<Modal className={styles.modal}>
<Dialog className={styles.dialog} aria-label={t("common:search")}>
<GlobalSearchContent
onClose={() => handleOpenChange(false)}
onClose={() => setIsOpen(false)}
initialSearchType={initialSearchType}
initialWeaponId={searchParamWeapon}
/>
</Dialog>
</Modal>
@ -148,12 +153,26 @@ export function GlobalSearch() {
);
}
function resolveInitialWeapon(
weaponIdStr: string | null,
t: TFunction<["common", "weapons"]>,
): SelectedWeapon | null {
if (!weaponIdStr) return null;
const id = Number(weaponIdStr) as MainWeaponId;
if (Number.isNaN(id)) return null;
const name = t(`weapons:MAIN_${id}`);
if (!name || name === `MAIN_${id}`) return null;
return { id, name, slug: mySlugify(name) };
}
function GlobalSearchContent({
onClose,
initialSearchType,
initialWeaponId,
}: {
onClose: () => void;
initialSearchType: SearchType | null;
initialWeaponId: string | null;
}) {
const { t } = useTranslation(["common", "weapons"]);
const navigate = useNavigate();
@ -162,7 +181,9 @@ function GlobalSearchContent({
initialSearchType ?? getInitialSearchType(),
);
const [selectedWeapon, setSelectedWeapon] =
React.useState<SelectedWeapon | null>(null);
React.useState<SelectedWeapon | null>(
resolveInitialWeapon(initialWeaponId, t),
);
const inputRef = React.useRef<HTMLInputElement>(null);
const listBoxRef = React.useRef<HTMLDivElement>(null);

View File

@ -14,7 +14,6 @@ import {
daily,
everyHourAt00,
everyHourAt30,
everyTwoHours,
everyTwoMinutes,
} from "./routines/list.server";
import { logger } from "./utils/logger";
@ -112,12 +111,6 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") {
}
});
cron.schedule("5 */2 * * *", async () => {
for (const routine of everyTwoHours) {
await routine.run();
}
});
cron.schedule("*/2 * * * *", async () => {
for (const routine of everyTwoMinutes) {
await routine.run();

View File

@ -303,6 +303,54 @@ describe("deleteById", () => {
});
});
describe("deleteOrphanTags", () => {
beforeEach(async () => {
imageCounter = 0;
await dbInsertUsers(1);
});
afterEach(() => {
dbReset();
});
test("deletes tags with no associated art", async () => {
const art = await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
description: null,
linkedUsers: [],
tags: [{ name: "Orphan1" }, { name: "Orphan2" }],
});
await ArtRepository.deleteById(art.id);
const deletedCount = await ArtRepository.deleteOrphanTags();
expect(deletedCount).toBe(2);
const tags = await ArtRepository.findAllTags();
expect(tags).toHaveLength(0);
});
test("does not delete tags that are still linked to art", async () => {
await ArtRepository.insert({
authorId: 1,
url: "https://example.com/image-1.png",
validatedAt: Date.now(),
description: null,
linkedUsers: [],
tags: [{ name: "InUse" }],
});
const deletedCount = await ArtRepository.deleteOrphanTags();
expect(deletedCount).toBe(0);
const tags = await ArtRepository.findAllTags();
expect(tags).toHaveLength(1);
expect(tags[0].name).toBe("InUse");
});
});
describe("insert", () => {
beforeEach(async () => {
imageCounter = 0;

View File

@ -171,6 +171,15 @@ export async function findAllTags() {
return db.selectFrom("ArtTag").select(["id", "name"]).execute();
}
export async function deleteOrphanTags() {
const result = await db
.deleteFrom("ArtTag")
.where("id", "not in", db.selectFrom("TaggedArt").select("TaggedArt.tagId"))
.executeTakeFirst();
return Number(result.numDeletedRows);
}
export async function findArtsByUserId(
userId: number,
{ includeAuthored = true, includeTagged = true } = {},

View File

@ -26,7 +26,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
? cachedArts.allTags.find((t) => t.name === filteredTagName)
: null;
if (!filteredTag) return cachedArts;
if (!filteredTag) {
return filteredTagName ? { ...cachedArts, showcaseArts: [] } : cachedArts;
}
return {
...cachedArts,

View File

@ -160,7 +160,13 @@ export default function ArtPage() {
<ArtGrid arts={recentlyUploadedArts} showUploadDate />
</SendouTabPanel>
<SendouTabPanel id={TABS.SHOWCASE}>
<ArtGrid arts={showcaseArts} />
{filteredTag && showcaseArts.length === 0 ? (
<div className="no-results mt-4">
{t("art:noArtForTag", { tag: filteredTag })}
</div>
) : (
<ArtGrid arts={showcaseArts} />
)}
</SendouTabPanel>
</SendouTabs>
</Main>

View File

@ -556,55 +556,71 @@ function useChatRouteSync({
React.SetStateAction<Record<string, ChatMessage[]>>
>;
}) {
const chatCode = useCurrentRouteChatCode();
const rawChatCode = useCurrentRouteChatCode();
const chatCodesKey = rawChatCode
? Array.isArray(rawChatCode)
? rawChatCode.join(",")
: rawChatCode
: "";
const { pathname } = useLocation();
const layoutSize = useLayoutSize();
const subscribedRoomRef = React.useRef<string | null>(null);
const previousRouteChatCodeRef = React.useRef<string | null>(null);
const subscribedRoomRef = React.useRef<string[]>([]);
const previousRouteChatCodeRef = React.useRef<string[]>([]);
const previousPathnameRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (isLoading) return;
const previousSubscribed = subscribedRoomRef.current;
const chatCodes = chatCodesKey ? chatCodesKey.split(",") : [];
// Clean up previous non-participant subscription if chatCode changed
if (previousSubscribed && previousSubscribed !== chatCode) {
unsubscribe(previousSubscribed);
setRooms((prev) => prev.filter((r) => r.chatCode !== previousSubscribed));
// Clean up subscriptions for rooms no longer in chatCodes
const previousSubscribed = subscribedRoomRef.current;
const removedRooms = previousSubscribed.filter(
(code) => !chatCodes.includes(code),
);
for (const code of removedRooms) {
unsubscribe(code);
setRooms((prev) => prev.filter((r) => r.chatCode !== code));
setMessagesByRoom((prev) => {
const { [previousSubscribed]: _, ...rest } = prev;
const { [code]: _, ...rest } = prev;
return rest;
});
if (activeRoom === previousSubscribed) {
if (activeRoom === code) {
setActiveRoom(null);
setChatOpen(false);
}
subscribedRoomRef.current = null;
}
if (removedRooms.length > 0) {
subscribedRoomRef.current = previousSubscribed.filter((code) =>
chatCodes.includes(code),
);
}
if (chatCode) {
const room = rooms.find((r) => r.chatCode === chatCode);
const isParticipant = room?.participantUserIds.includes(userId);
if (chatCodes.length > 0) {
for (const code of chatCodes) {
const alreadyInRooms = rooms.some((r) => r.chatCode === code);
if (!isParticipant && subscribedRoomRef.current !== chatCode) {
logger.debug("Subscribing to non-participant room:", chatCode);
subscribe(chatCode);
subscribedRoomRef.current = chatCode;
if (!alreadyInRooms && !subscribedRoomRef.current.includes(code)) {
logger.debug("Subscribing to non-participant room:", code);
subscribe(code);
subscribedRoomRef.current = [...subscribedRoomRef.current, code];
}
}
const previousCodes = previousRouteChatCodeRef.current;
const routeChatCodeChanged =
previousRouteChatCodeRef.current !== chatCode;
previousRouteChatCodeRef.current = chatCode;
chatCodes.length !== previousCodes.length ||
chatCodes.some((code, i) => previousCodes[i] !== code);
previousRouteChatCodeRef.current = chatCodes;
if (routeChatCodeChanged) {
setActiveRoom(chatCode);
setActiveRoom(chatCodes[0]);
if (layoutSize === "desktop") {
setChatOpen(true);
}
}
} else {
previousRouteChatCodeRef.current = null;
previousRouteChatCodeRef.current = [];
const pathnameChanged = previousPathnameRef.current !== pathname;
previousPathnameRef.current = pathname;
@ -624,7 +640,7 @@ function useChatRouteSync({
}
}, [
isLoading,
chatCode,
chatCodesKey,
pathname,
rooms,
userId,
@ -639,11 +655,13 @@ function useChatRouteSync({
]);
}
function useCurrentRouteChatCode() {
function useCurrentRouteChatCode(): string | string[] | null {
const matches = useMatches();
for (const match of matches) {
const matchData = match.data as { chatCode?: string } | undefined;
const matchData = match.data as
| { chatCode?: string | string[] }
| undefined;
if (matchData?.chatCode) {
return matchData.chatCode;
}

View File

@ -1,9 +1,11 @@
import cachified from "@epic-web/cachified";
import type { Tables } from "~/db/tables";
import { getUser } from "~/features/auth/core/user.server";
import * as Changelog from "~/features/front-page/core/Changelog.server";
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import type { SerializeFrom } from "~/utils/remix";
@ -13,26 +15,35 @@ import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
export type FrontPageLoaderData = SerializeFrom<typeof loader>;
export const loader = async () => {
const [tournaments, changelog, leaderboards, rotations] = await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(null),
cachified({
key: "front-changelog",
cache,
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return Changelog.get();
},
}),
cachedLeaderboards(),
SplatoonRotationRepository.findAll(),
]);
const user = getUser();
const [tournaments, changelog, leaderboards, rotations, weaponPool] =
await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(null),
cachified({
key: "front-changelog",
cache,
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return Changelog.get();
},
}),
cachedLeaderboards(),
SplatoonRotationRepository.findAll(),
user
? QSettingsRepository.settingsByUserId(user.id).then(
(s) => s.qWeaponPool ?? null,
)
: Promise.resolve(null),
]);
return {
tournaments,
changelog,
leaderboards,
rotations,
weaponPool,
};
};

View File

@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { Image } from "~/components/Image";
import { Image, WeaponImage } from "~/components/Image";
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
import { BSKYLikeIcon } from "~/components/icons/BSKYLike";
import { BSKYReplyIcon } from "~/components/icons/BSKYReply";
import { BSKYRepostIcon } from "~/components/icons/BSKYRepost";
import { ExternalIcon } from "~/components/icons/External";
import { navItems } from "~/components/layout/nav-items";
import { Main } from "~/components/Main";
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
@ -41,6 +42,7 @@ export default function FrontPage() {
<SeasonBanner />
<SplatoonRotations />
<ResultHighlights />
<DiscoverFeatures />
<ChangelogList />
</Main>
);
@ -227,6 +229,61 @@ function Leaderboard({
);
}
const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
function DiscoverFeatures() {
const { t } = useTranslation(["front", "common"]);
const data = useLoaderData<typeof loader>();
const filteredNavItems = navItems.filter(
(item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
);
return (
<div className="stack md">
<Divider smallText className="text-uppercase text-xs font-bold">
{t("front:discover.header")}
</Divider>
{data.weaponPool && data.weaponPool.length > 0 ? (
<div className={styles.weaponPills}>
{data.weaponPool.map((weapon) => (
<Link
key={weapon.weaponSplId}
to={`?search=open&type=weapons&weapon=${weapon.weaponSplId}`}
className={styles.weaponPill}
>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={32}
/>
</Link>
))}
</div>
) : null}
<nav className={styles.discoverGrid}>
{filteredNavItems.map((item) => (
<Link
key={item.name}
to={`/${item.url}`}
className={styles.discoverGridItem}
>
<div className={styles.discoverGridItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
))}
</nav>
</div>
);
}
function ChangelogList() {
const { t } = useTranslation(["front"]);
const data = useLoaderData<typeof loader>();

View File

@ -45,15 +45,30 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
})
: null,
rawReportedWeapons,
chatCode:
(user?.roles.includes("STAFF") ||
(user && matchUsers.includes(user.id))) &&
chatAccessible({
isStaff: user?.roles.includes("STAFF") ?? false,
chatCode: (() => {
const isStaff = user?.roles.includes("STAFF") ?? false;
const isParticipant = user && matchUsers.includes(user.id);
if (!(isStaff || isParticipant)) return null;
const accessible = chatAccessible({
isStaff,
expiresAfterDays: 1,
comparedTo: databaseTimestampToDate(matchUnmapped.createdAt),
})
? match.chatCode
: null,
});
if (!accessible) return null;
if (!isParticipant) return match.chatCode ?? null;
const codes = [
match.chatCode,
match.groupAlpha.chatCode,
match.groupBravo.chatCode,
].filter((c): c is string => Boolean(c));
if (codes.length === 0) return null;
if (codes.length === 1) return codes[0];
return codes;
})(),
};
};

View File

@ -9,6 +9,7 @@ import {
matchMapList,
} from "~/features/sendouq-match/core/match.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
@ -177,6 +178,7 @@ export const action: ActionFunction = async ({ request }) => {
});
await refreshSendouQInstance();
refreshStreamsCache();
if (createdMatch.chatCode) {
ChatSystemMessage.setMetadata({

View File

@ -9,6 +9,7 @@ import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import { preparingSchema } from "../q-schemas.server";
import { setGroupChatMetadata } from "../q-utils.server";
export type SendouQPreparingAction = typeof action;
@ -58,6 +59,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
await refreshSendouQInstance();
const updatedGroup = SendouQ.findOwnGroup(user.id);
if (updatedGroup?.chatCode) {
setGroupChatMetadata({
chatCode: updatedGroup.chatCode,
members: updatedGroup.members,
});
}
notify({
userIds: [data.id],
notification: {

View File

@ -21,7 +21,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
userId: user.id,
});
if (!ownTournamentTeam)
if (!ownTournamentTeam) {
return {
mapPool: null,
friendPlayers: null,
@ -31,6 +31,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
}),
};
}
return {
mapPool: findMapPoolByTeamId(ownTournamentTeam.id),

View File

@ -16,6 +16,7 @@ const stm = sql.prepare(/*sql*/ `
and "TournamentTeamMember"."isOwner" = 1
where
"TournamentTeam"."tournamentId" = @tournamentId
and "TournamentTeam"."isPlaceholder" = 0
and "TournamentTeamMember"."userId" = @userId
`);

View File

@ -368,7 +368,7 @@ export default function App() {
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
htmlStyle.overflow = "initial";
bodyStyle.overflow = "hidden";
bodyStyle.paddingRight = `${scrollbarWidth}px`;

View File

@ -0,0 +1,11 @@
import * as ArtRepository from "../features/art/ArtRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOrphanArtTagsRoutine = new Routine({
name: "DeleteOrphanArtTags",
func: async () => {
const deletedCount = await ArtRepository.deleteOrphanTags();
logger.info(`Deleted ${deletedCount} orphan art tags`);
},
});

View File

@ -1,5 +1,6 @@
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
@ -15,6 +16,7 @@ export const everyHourAt00 = [
NotifyPlusServerVotingRoutine,
NotifyCheckInStartRoutine,
NotifyScrimStartingSoonRoutine,
SyncSplatoonRotationsRoutine,
];
/** List of Routines that should occur hourly at XX:30 */
@ -27,10 +29,8 @@ export const everyHourAt30 = [
export const daily = [
DeleteOldNotificationsRoutine,
CloseExpiredCommissionsRoutine,
DeleteOrphanArtTagsRoutine,
];
/** List of Routines that should occur every 2 hours */
export const everyTwoHours = [SyncSplatoonRotationsRoutine];
/** List of Routines that should occur every 2 minutes */
export const everyTwoMinutes = [SyncLiveStreamsRoutine];

View File

@ -8,8 +8,6 @@ export const SyncSplatoonRotationsRoutine = new Routine({
});
async function syncSplatoonRotations() {
if (import.meta.env.VITE_PROD_MODE !== "true") return;
const rotations = await fetchRotations();
await SplatoonRotationRepository.replaceAll(rotations);
}

View File

@ -165,3 +165,62 @@
gap: var(--s-2);
flex-wrap: wrap;
}
.weaponPills {
display: flex;
gap: var(--s-2);
justify-content: center;
flex-wrap: wrap;
}
.weaponPill {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-1);
transition: background-color 0.15s ease-out;
}
.weaponPill:hover {
background-color: var(--color-bg-high);
}
.discoverGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--s-3);
padding: var(--s-2);
}
@media (min-width: 600px) {
.discoverGrid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
}
.discoverGridItem {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
text-decoration: none;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-align: center;
}
.discoverGridItem:hover .discoverGridItemImage {
background-color: var(--color-bg-high);
}
.discoverGridItemImage {
width: var(--field-size-lg);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
background-color: var(--color-bg-higher);
display: grid;
place-items: center;
}

View File

@ -29,5 +29,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Maks antal etiketter opnået",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -29,5 +29,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -29,5 +29,6 @@
"forms.tags.placeholder": "Select a tag",
"forms.tags.maxReached": "Max tags reached",
"delete.title": "Are you sure you want to delete the art?",
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?"
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?",
"noArtForTag": "No uploaded art found for #{{tag}}"
}

View File

@ -38,5 +38,6 @@
"rotations.current": "Now",
"rotations.nextLabel": "Next",
"rotations.credit": "Data from splatoon3.ink",
"rotations.filter.all": "All"
"rotations.filter.all": "All",
"discover.header": "Discover all the features"
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "Selecciona una etiqueta",
"forms.tags.maxReached": "Limite máximo de etiquetas",
"delete.title": "¿Seguro que quieres eliminar este arte?",
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)"
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite máximo de etiquetas",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite de tags atteinte",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite de tags atteinte",
"delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?",
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)"
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "הגעת למקסימום תגים",
"forms.tags.maxReached": "הגעת לכמות תגים מקסימלית",
"delete.title": "האם אתה בטוח שאתה רוצה למחוק את הציור?",
"unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?"
"unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Numero massimo di tag raggiunto.",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -27,5 +27,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "タグの最大数に到達しました",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -27,5 +27,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -29,5 +29,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -31,5 +31,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -30,5 +30,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Quantidade máxima de marcações atingida",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -31,5 +31,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Максимальное кол-во тегов достигнуто",
"delete.title": "Вы точно хотите удалить этот арт?",
"unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?"
"unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -27,5 +27,6 @@
"forms.tags.placeholder": "",
"forms.tags.maxReached": "已达到最大标签数",
"delete.title": "",
"unlink.title": ""
"unlink.title": "",
"noArtForTag": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}