* Have clicking pagination dots do a thing

* Add tag frontend

* Backend

* Render tags

* Filter by tag

* Persist big art to search params

* Filter by tags on the common art page

* Linking tags + user tags
This commit is contained in:
Kalle 2023-07-12 16:25:46 +03:00 committed by GitHub
parent 172e251c97
commit 7b18a0cfc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 663 additions and 122 deletions

View File

@ -482,6 +482,18 @@ export interface Art {
createdAt: number;
}
export interface ArtTag {
id: number;
name: string;
authorId: number;
createdAt: number;
}
export interface TaggedArt {
artId: number;
tagId: number;
}
export interface ArtUserMetadata {
artId: number;
userId: number;

View File

@ -6,6 +6,8 @@ export const ART = {
DESCRIPTION_MAX_LENGTH: TWEET_LENGTH_MAX_LENGTH / 2,
LINKED_USERS_MAX_LENGTH: 10,
THUMBNAIL_WIDTH: 640,
TAG_MAX_LENGTH: 100,
TAGS_MAX_LENGTH: 25,
};
export const NEW_ART_EXISTING_SEARCH_PARAM_KEY = "art";

View File

@ -18,14 +18,27 @@ const linkedUsers = z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(id).max(ART.LINKED_USERS_MAX_LENGTH)
);
const tags = z.preprocess(
safeJSONParse,
z
.array(
z.object({
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
id: id.optional(),
})
)
.max(ART.TAG_MAX_LENGTH)
);
export const newArtSchema = z.object({
description,
linkedUsers,
tags,
});
export const editArtSchema = z.object({
description,
linkedUsers,
tags,
isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
});

View File

@ -4,6 +4,13 @@ export interface ListedArt {
id: Art["id"];
url: UserSubmittedImage["url"];
description?: Art["description"];
tags?: string[];
linkedUsers?: Array<{
discordId: User["discordId"];
discordName: User["discordName"];
discordDiscriminator: User["discordDiscriminator"];
customUrl: User["customUrl"];
}>;
author?: {
discordId: User["discordId"];
discordName: User["discordName"];

View File

@ -4,9 +4,11 @@ import { Avatar } from "~/components/Avatar";
import { useIsMounted } from "~/hooks/useIsMounted";
import { discordFullName } from "~/utils/strings";
import {
artPage,
conditionalUserSubmittedImage,
newArtPage,
userArtPage,
userPage,
} from "~/utils/urls";
import type { ListedArt } from "../art-types";
import { Dialog } from "~/components/Dialog";
@ -23,6 +25,7 @@ import { useTranslation } from "~/hooks/useTranslation";
import { previewUrl } from "../art-utils";
import { TrashIcon } from "~/components/icons/Trash";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { useSearchParamState } from "~/hooks/useSearchParamState";
export function ArtGrid({
arts,
@ -40,19 +43,27 @@ export function ArtGrid({
pagesCount,
nextPage,
previousPage,
setPage,
} = useSimplePagination({
items: arts,
pageSize: ART_PER_PAGE,
});
const [bigArt, setBigArt] = React.useState<ListedArt | null>(null);
const [bigArtId, setBigArtId] = useSearchParamState<number | null>({
defaultValue: null,
name: "big",
revive: (value) =>
itemsToDisplay.find((art) => art.id === Number(value))?.id,
});
const isMounted = useIsMounted();
if (!isMounted) return null;
const bigArt = itemsToDisplay.find((art) => art.id === bigArtId);
return (
<>
{bigArt ? (
<BigImageDialog close={() => setBigArt(null)} art={bigArt} />
<BigImageDialog close={() => setBigArtId(null)} art={bigArt} />
) : null}
<ResponsiveMasonry columnsCountBreakPoints={{ 350: 1, 750: 2, 900: 3 }}>
<Masonry gutter="1rem">
@ -62,7 +73,7 @@ export function ArtGrid({
art={art}
canEdit={canEdit}
enablePreview={enablePreview}
onClick={enablePreview ? () => setBigArt(art) : undefined}
onClick={enablePreview ? () => setBigArtId(art.id) : undefined}
/>
))}
</Masonry>
@ -73,6 +84,7 @@ export function ArtGrid({
pagesCount={pagesCount}
nextPage={nextPage}
previousPage={previousPage}
setPage={setPage}
/>
) : null}
</>
@ -96,6 +108,24 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
className="art__dialog__img"
onLoad={() => setImageLoaded(true)}
/>
{art.tags || art.linkedUsers ? (
<div className="stack sm horizontal">
{art.linkedUsers?.map((user) => (
<Link
to={userPage(user)}
key={user.discordId}
className="art__dialog__tag art__dialog__tag__user"
>
{discordFullName(user)}
</Link>
))}
{art.tags?.map((tag) => (
<Link to={artPage(tag)} key={tag} className="art__dialog__tag">
#{tag}
</Link>
))}
</div>
) : null}
{art.description ? (
<div
className={clsx("art__dialog__description", {
@ -201,11 +231,13 @@ function SimplePagination({
pagesCount,
nextPage,
previousPage,
setPage,
}: {
currentPage: number;
pagesCount: number;
nextPage: () => void;
previousPage: () => void;
setPage: (page: number) => void;
}) {
return (
<div className="stack sm horizontal items-center justify-center flex-wrap">
@ -222,6 +254,7 @@ function SimplePagination({
className={clsx("pagination__dot", {
pagination__dot__active: i === currentPage - 1,
})}
onClick={() => setPage(i + 1)}
/>
))}
<Button

View File

@ -1,5 +1,6 @@
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
import type { Art, UserSubmittedImage } from "~/db/types";
import type { Art, ArtTag, UserSubmittedImage } from "~/db/types";
const addImgStm = sql.prepare(/* sql */ `
insert into "UnvalidatedUserSubmittedImage"
@ -35,6 +36,28 @@ const addArtStm = sql.prepare(/* sql */ `
returning *
`);
const addArtTagStm = sql.prepare(/* sql */ `
insert into "ArtTag"
("name", "authorId")
values
(@name, @authorId)
returning *
`);
const addTaggedArtStm = sql.prepare(/* sql */ `
insert into "TaggedArt"
("artId", "tagId")
values
(@artId, @tagId)
`);
const deleteAllTaggedArtStm = sql.prepare(/* sql */ `
delete from
"TaggedArt"
where
"artId" = @artId
`);
const updateArtStm = sql.prepare(/* sql */ `
update
"Art"
@ -68,8 +91,12 @@ const removeUserMetadataStm = sql.prepare(/* sql */ `
"artId" = @artId
`);
type TagsToAdd = Array<Partial<Pick<ArtTag, "name" | "id">>>;
type AddNewArtArgs = Pick<Art, "authorId" | "description"> &
Pick<UserSubmittedImage, "url" | "validatedAt"> & { linkedUsers: number[] };
Pick<UserSubmittedImage, "url" | "validatedAt"> & {
linkedUsers: number[];
tags: TagsToAdd;
};
export const addNewArt = sql.transaction((args: AddNewArtArgs) => {
const img = addImgStm.get(args) as UserSubmittedImage;
@ -78,11 +105,27 @@ export const addNewArt = sql.transaction((args: AddNewArtArgs) => {
for (const userId of args.linkedUsers) {
addArtUserMetadataStm.run({ artId: art.id, userId });
}
for (const tag of args.tags) {
let tagId = tag.id;
if (!tagId) {
invariant(tag.name, "tag name must be provided if no id");
const newTag = addArtTagStm.get({
name: tag.name,
authorId: args.authorId,
}) as ArtTag;
tagId = newTag.id;
}
addTaggedArtStm.run({ artId: art.id, tagId });
}
});
type EditArtArgs = Pick<Art, "authorId" | "description" | "isShowcase"> & {
linkedUsers: number[];
artId: number;
tags: TagsToAdd;
};
export const editArt = sql.transaction((args: EditArtArgs) => {
@ -102,4 +145,20 @@ export const editArt = sql.transaction((args: EditArtArgs) => {
for (const userId of args.linkedUsers) {
addArtUserMetadataStm.run({ artId: args.artId, userId });
}
deleteAllTaggedArtStm.run({ artId: args.artId });
for (const tag of args.tags) {
let tagId = tag.id;
if (!tagId) {
invariant(tag.name, "tag name must be provided if no id");
const newTag = addArtTagStm.get({
name: tag.name,
authorId: args.authorId,
}) as ArtTag;
tagId = newTag.id;
}
addTaggedArtStm.run({ artId: args.artId, tagId });
}
});

View File

@ -0,0 +1,14 @@
import { sql } from "~/db/sql";
import type { ArtTag } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"id",
"name"
from
"ArtTag"
`);
export function allArtTags(): Array<Pick<ArtTag, "id" | "name">> {
return stm.all() as any;
}

View File

@ -1,57 +1,92 @@
import { sql } from "~/db/sql";
import type { ListedArt } from "../art-types";
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
with "q1" as (
select
"Art"."id",
"Art"."description",
"Art"."createdAt",
"User"."discordId",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordAvatar",
"UserSubmittedImage"."url"
from
"Art"
left join "User" on "User"."id" = "Art"."authorId"
left join "ArtUserMetadata" on "ArtUserMetadata"."artId" = "Art"."id"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where "ArtUserMetadata"."userId" = @userId
and "Art"."authorId" != @userId
union all
select
"Art"."id",
"Art"."description",
"Art"."createdAt",
null, -- discordId
null, -- discordName
null, -- discordDiscriminator
null, -- discordAvatar
"UserSubmittedImage"."url"
from
"Art"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where
"Art"."authorId" = @userId
order by "Art"."createdAt" desc
),
"q2" as (
select
"q1".*,
json_group_array("ArtTag"."name") as "tags"
from
"q1"
left join "TaggedArt" on "TaggedArt"."artId" = "q1"."id"
left join "ArtTag" on "ArtTag"."id" = "TaggedArt"."tagId"
group by "q1"."id"
)
select
"Art"."id",
"Art"."description",
"Art"."createdAt",
"User"."discordId",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordAvatar",
"UserSubmittedImage"."url"
"q2".*,
json_group_array(
json_object(
'discordId', "LinkedUser"."discordId",
'discordName', "LinkedUser"."discordName",
'discordDiscriminator', "LinkedUser"."discordDiscriminator",
'customUrl', "LinkedUser"."customUrl"
)
) as "linkedUsers"
from
"Art"
left join "User" on "User"."id" = "Art"."authorId"
left join "ArtUserMetadata" on "ArtUserMetadata"."artId" = "Art"."id"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where "ArtUserMetadata"."userId" = @userId
and "Art"."authorId" != @userId
union all
select
"Art"."id",
"Art"."description",
"Art"."createdAt",
null, -- discordId
null, -- discordName
null, -- discordDiscriminator
null, -- discordAvatar
"UserSubmittedImage"."url"
from
"Art"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where
"Art"."authorId" = @userId
order by "Art"."createdAt" desc
"q2"
left join "ArtUserMetadata" on "ArtUserMetadata"."artId" = "q2"."id"
left join "User" as "LinkedUser" on "LinkedUser"."id" = "ArtUserMetadata"."userId"
group by "q2"."id"
`);
export function artsByUserId(userId: number): ListedArt[] {
return stm.all({ userId }).map((a: any) => ({
id: a.id,
url: a.url,
description: a.description,
author: a.discordId
? {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordDiscriminator: a.discordDiscriminator,
discordId: a.discordId,
discordName: a.discordName,
}
: undefined,
}));
return stm.all({ userId }).map((a: any) => {
const tags = parseDBArray(a.tags) as any[];
const linkedUsers = parseDBJsonArray(a.linkedUsers) as any[];
return {
id: a.id,
url: a.url,
description: a.description,
tags: tags.length > 0 ? tags : undefined,
linkedUsers: linkedUsers.length > 0 ? linkedUsers : undefined,
author: a.discordId
? {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordDiscriminator: a.discordDiscriminator,
discordId: a.discordId,
discordName: a.discordName,
}
: undefined,
};
});
}

View File

@ -1,8 +1,8 @@
import { sql } from "~/db/sql";
import type { Art, User, UserSubmittedImage } from "~/db/types";
import type { Art, ArtTag, User, UserSubmittedImage } from "~/db/types";
import { parseDBArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
const findArtStm = sql.prepare(/* sql */ `
select
"Art"."isShowcase",
"Art"."description",
@ -16,20 +16,31 @@ const stm = sql.prepare(/* sql */ `
group by "Art"."id"
`);
const findTagsStm = sql.prepare(/* sql */ `
select
"ArtTag"."id",
"ArtTag"."name"
from "ArtTag"
inner join "TaggedArt" on "ArtTag"."id" = "TaggedArt"."tagId"
where "TaggedArt"."artId" = @artId
`);
interface FindArtById {
isShowcase: Art["isShowcase"];
description: Art["description"];
url: UserSubmittedImage["url"];
authorId: Art["authorId"];
linkedUsers: User["id"][];
tags: Array<Pick<ArtTag, "id" | "name">>;
}
export function findArtById(artId: number): FindArtById | null {
const art = stm.get({ artId }) as any;
const art = findArtStm.get({ artId }) as any;
if (!art) return null;
return {
...art,
linkedUsers: parseDBArray(art.linkedUsers),
tags: findTagsStm.all({ artId }),
};
}

View File

@ -1,9 +1,11 @@
import { sql } from "~/db/sql";
import type { ListedArt } from "../art-types";
import type { ArtTag } from "~/db/types";
const stm = sql.prepare(/* sql */ `
const showcaseArtsStm = sql.prepare(/* sql */ `
select
"Art"."id",
"User"."id" as "userId",
"User"."discordId",
"User"."discordName",
"User"."discordDiscriminator",
@ -20,7 +22,7 @@ const stm = sql.prepare(/* sql */ `
`);
export function showcaseArts(): ListedArt[] {
return stm.all().map((a: any) => ({
return showcaseArtsStm.all().map((a: any) => ({
id: a.id,
url: a.url,
author: {
@ -32,3 +34,51 @@ export function showcaseArts(): ListedArt[] {
},
}));
}
const showcaseArtsByTagStm = sql.prepare(/* sql */ `
select
"Art"."id",
"User"."id" as "userId",
"User"."discordId",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordAvatar",
"User"."commissionsOpen",
"UserSubmittedImage"."url"
from
"TaggedArt"
inner join "Art" on "Art"."id" = "TaggedArt"."artId"
left join "User" on "User"."id" = "Art"."authorId"
inner join "UserSubmittedImage" on "UserSubmittedImage"."id" = "Art"."imgId"
where
"TaggedArt"."tagId" = @tagId
order by
"Art"."isShowcase" desc, random()
`);
export function showcaseArtsByTag(tagId: ArtTag["id"]): ListedArt[] {
const encounteredUserIds = new Set<number>();
return showcaseArtsByTagStm
.all({ tagId })
.filter((row: any) => {
if (encounteredUserIds.has(row.userId)) {
return false;
}
encounteredUserIds.add(row.userId);
return true;
})
.map((a: any) => ({
id: a.id,
url: a.url,
author: {
commissionsOpen: a.commissionsOpen,
discordAvatar: a.discordAvatar,
discordDiscriminator: a.discordDiscriminator,
discordId: a.discordId,
discordName: a.discordName,
},
}));
}

View File

@ -13,7 +13,7 @@ import * as React from "react";
import { useFetcher } from "react-router-dom";
import invariant from "tiny-invariant";
import { Button } from "~/components/Button";
import { UserCombobox } from "~/components/Combobox";
import { Combobox, UserCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -30,7 +30,7 @@ import {
parseRequestFormData,
} from "~/utils/remix";
import {
ART_PAGE,
artPage,
conditionalUserSubmittedImage,
navIconUrl,
userArtPage,
@ -40,12 +40,13 @@ import { editArtSchema, newArtSchema } from "../art-schemas.server";
import { addNewArt, editArt } from "../queries/addNewArt.server";
import { findArtById } from "../queries/findArtById.server";
import { previewUrl } from "../art-utils";
import { allArtTags } from "../queries/allArtTags.server";
export const handle: SendouRouteHandle = {
i18n: ["art"],
breadcrumb: () => ({
imgPath: navIconUrl("art"),
href: ART_PAGE,
href: artPage(),
type: "IMAGE",
}),
};
@ -79,6 +80,7 @@ export const action: ActionFunction = async ({ request }) => {
description: data.description,
isShowcase: data.isShowcase,
linkedUsers: data.linkedUsers,
tags: data.tags,
});
} else {
const uploadHandler = composeUploadHandlers(
@ -104,6 +106,7 @@ export const action: ActionFunction = async ({ request }) => {
url: fileName,
validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null,
linkedUsers: data.linkedUsers,
tags: data.tags,
});
}
@ -123,7 +126,7 @@ export const loader = async ({ request }: LoaderArgs) => {
const art = findArtById(artId);
if (!art || art.authorId !== user.id) return null;
return { art };
return { art, tags: allArtTags() };
};
export default function NewArtPage() {
@ -152,6 +155,7 @@ export default function NewArtPage() {
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
<ImageUpload img={img} setImg={setImg} setSmallImg={setSmallImg} />
<Description />
<Tags />
<LinkedUsers />
{data?.art ? <ShowcaseToggle /> : null}
<div>
@ -256,6 +260,128 @@ function Description() {
);
}
function Tags() {
const data = useLoaderData<typeof loader>();
const [creationMode, setCreationMode] = React.useState(false);
const [tags, setTags] = React.useState<{ name?: string; id?: number }[]>(
data?.art.tags ?? []
);
const [newTagValue, setNewTagValue] = React.useState("");
const existingTags = data?.tags ?? [];
const unselectedTags = existingTags.filter(
(t) => !tags.some((tag) => tag.id === t.id)
);
const handleAddNewTag = () => {
const normalizedNewTagValue = newTagValue
.trim()
// replace many whitespaces with one
.replace(/\s\s+/g, " ")
.toLowerCase();
if (
normalizedNewTagValue.length === 0 ||
normalizedNewTagValue.length > ART.TAG_MAX_LENGTH
) {
return;
}
const alreadyCreatedTag = existingTags.find(
(t) => t.name === normalizedNewTagValue
);
if (alreadyCreatedTag) {
setTags((tags) => [...tags, alreadyCreatedTag]);
} else if (tags.every((tag) => tag.name !== normalizedNewTagValue)) {
setTags((tags) => [...tags, { name: normalizedNewTagValue }]);
}
setNewTagValue("");
setCreationMode(false);
};
return (
<div className="stack xs items-start">
<Label htmlFor="tags" className="mb-0">
Tags
</Label>
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
{creationMode ? (
<div className="art__creation-mode-switcher-container">
<Button variant="minimal" onClick={() => setCreationMode(false)}>
Select from existing tags
</Button>
</div>
) : (
<div className="stack horizontal sm text-xs text-lighter art__creation-mode-switcher-container">
Can&apos;t find an existing tag?{" "}
<Button variant="minimal" onClick={() => setCreationMode(true)}>
Create a new one.
</Button>
</div>
)}
{tags.length >= ART.TAGS_MAX_LENGTH ? (
<div className="text-sm text-warning">Max tags reached</div>
) : creationMode ? (
<div className="stack horizontal sm items-center">
<input
placeholder="Create a new tag"
name="tag"
value={newTagValue}
onChange={(e) => setNewTagValue(e.target.value)}
onKeyDown={(event) => {
if (event.code === "Enter") {
handleAddNewTag();
}
}}
/>
<Button size="tiny" variant="outlined" onClick={handleAddNewTag}>
Add
</Button>
</div>
) : (
<Combobox
// empty combobox on select
key={tags.length}
options={unselectedTags.map((t) => ({
label: t.name,
value: String(t.id),
}))}
inputName="tags"
placeholder="Search existing tags"
initialValue={null}
onChange={(selection) => {
if (!selection) return;
setTags([
...tags,
{ name: selection.label, id: Number(selection.value) },
]);
}}
/>
)}
<div className="text-sm stack sm flex-wrap horizontal">
{tags.map((t) => {
return (
<div key={t.name} className="stack horizontal">
{t.name}{" "}
<Button
icon={<CrossIcon />}
size="tiny"
variant="minimal-destructive"
className="art__delete-tag-button"
onClick={() => {
setTags(tags.filter((tag) => tag.name !== t.name));
}}
/>
</div>
);
})}
</div>
</div>
);
}
function LinkedUsers() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
@ -313,6 +439,7 @@ function LinkedUsers() {
onClick={() => setUsers([...users, { inputId: nanoid() }])}
disabled={users.length >= ART.LINKED_USERS_MAX_LENGTH}
className="my-3"
variant="outlined"
>
{t("art:forms.linkedUsers.anotherOne")}
</Button>

View File

@ -3,24 +3,43 @@ import type {
SerializeFrom,
V2_MetaFunction,
} from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { Combobox } from "~/components/Combobox";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import { useTranslation } from "~/hooks/useTranslation";
import { i18next } from "~/modules/i18n";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { ART_PAGE, navIconUrl } from "~/utils/urls";
import { artPage, navIconUrl } from "~/utils/urls";
import { ArtGrid } from "../components/ArtGrid";
import { showcaseArts } from "../queries/showcaseArts.server";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTranslation } from "~/hooks/useTranslation";
import { allArtTags } from "../queries/allArtTags.server";
import {
showcaseArts,
showcaseArtsByTag,
} from "../queries/showcaseArts.server";
import { Button } from "~/components/Button";
import { CrossIcon } from "~/components/icons/Cross";
const FILTERED_TAG_KEY = "tag";
const OPEN_COMMISIONS_KEY = "open";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const currentFilteredTag = args.currentUrl.searchParams.get(FILTERED_TAG_KEY);
const nextFilteredTag = args.nextUrl.searchParams.get(FILTERED_TAG_KEY);
if (currentFilteredTag === nextFilteredTag) return false;
return args.defaultShouldRevalidate;
};
export const handle: SendouRouteHandle = {
i18n: ["art"],
breadcrumb: () => ({
imgPath: navIconUrl("art"),
href: ART_PAGE,
href: artPage(),
type: "IMAGE",
}),
};
@ -36,8 +55,16 @@ export const meta: V2_MetaFunction = (args) => {
export const loader = async ({ request }: LoaderArgs) => {
const t = await i18next.getFixedT(request);
const allTags = allArtTags();
const filteredTagName = new URL(request.url).searchParams.get(
FILTERED_TAG_KEY
);
const filteredTag = allTags.find((t) => t.name === filteredTagName);
return {
arts: showcaseArts(),
arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(),
allTags,
title: makeTitle(t("pages.art")),
};
};
@ -45,11 +72,10 @@ export const loader = async ({ request }: LoaderArgs) => {
export default function ArtPage() {
const { t } = useTranslation(["art"]);
const data = useLoaderData<typeof loader>();
const [showOpenCommissions, setShowOpenCommissions] = useSearchParamState({
defaultValue: false,
name: "open",
revive: (value) => value === "true",
});
const [searchParams, setSearchParams] = useSearchParams();
const filteredTag = searchParams.get(FILTERED_TAG_KEY);
const showOpenCommissions = searchParams.get(OPEN_COMMISIONS_KEY) === "true";
const arts = !showOpenCommissions
? data.arts
@ -57,16 +83,59 @@ export default function ArtPage() {
return (
<Main className="stack lg">
<div className="stack horizontal sm text-sm font-semi-bold">
<Toggle
checked={showOpenCommissions}
setChecked={setShowOpenCommissions}
id="open"
<div className="stack horizontal md justify-between items-center flex-wrap">
<div className="stack horizontal sm text-sm font-semi-bold">
<Toggle
checked={showOpenCommissions}
setChecked={() =>
setSearchParams((prev) => {
prev.set(OPEN_COMMISIONS_KEY, String(!showOpenCommissions));
return prev;
})
}
id="open"
/>
<Label htmlFor="open" className="m-auto-0">
{t("art:openCommissionsOnly")}
</Label>
</div>
<Combobox
key={filteredTag}
options={data.allTags.map((t) => ({
label: t.name,
value: String(t.id),
}))}
inputName="tags"
placeholder="Filter by tag"
initialValue={null}
onChange={(selection) => {
if (!selection) return;
setSearchParams((prev) => {
prev.set(FILTERED_TAG_KEY, selection.label);
return prev;
});
}}
/>
<Label htmlFor="open" className="m-auto-0">
{t("art:openCommissionsOnly")}
</Label>
</div>
{filteredTag ? (
<div className="text-xs text-lighter stack md horizontal items-center">
Showing results filtered by #{filteredTag}{" "}
<Button
size="tiny"
variant="minimal-destructive"
icon={<CrossIcon />}
onClick={() => {
setSearchParams((prev) => {
prev.delete(FILTERED_TAG_KEY);
return prev;
});
}}
>
Clear
</Button>
</div>
) : null}
<ArtGrid arts={arts} />
</Main>
);

View File

@ -29,6 +29,16 @@ export function useSimplePagination<T>({
}
}, [currentPage]);
const setPage = React.useCallback(
(page: number) => {
if (page > 0 && page <= pagesCount) {
setCurrentPage(page);
window.scrollTo(0, 0);
}
},
[pagesCount]
);
const thereIsNextPage = currentPage < pagesCount;
const thereIsPreviousPage = currentPage > 1;
@ -44,6 +54,7 @@ export function useSimplePagination<T>({
itemsToDisplay,
nextPage,
previousPage,
setPage,
thereIsNextPage,
thereIsPreviousPage,
everythingVisible: items.length === itemsToDisplay.length,

View File

@ -55,13 +55,30 @@ export const loader = async ({ params, request }: LoaderArgs) => {
const { identifier } = userParamsSchema.parse(params);
const user = notFoundIfFalsy(db.users.findByIdentifier(identifier));
const arts = artsByUserId(user.id);
const tagCounts = arts.reduce((acc, art) => {
if (!art.tags) return acc;
for (const tag of art.tags) {
acc[tag] = (acc[tag] ?? 0) + 1;
}
return acc;
}, {} as Record<string, number>);
const tagCountsSortedArr = Object.entries(tagCounts).sort(
(a, b) => b[1] - a[1]
);
return {
arts: artsByUserId(user.id),
arts,
tagCounts: tagCountsSortedArr.length > 0 ? tagCountsSortedArr : null,
unvalidatedArtCount:
user.id === loggedInUser?.id ? countUnvalidatedArt(user.id) : 0,
};
};
const ALL_TAGS_KEY = "ALL";
export default function UserArtPage() {
const { t } = useTranslation(["art"]);
const user = useUser();
@ -71,6 +88,11 @@ export default function UserArtPage() {
name: "source",
revive: (value) => ART_SOURCES.find((s) => s === value),
});
const [filteredTag, setFilteredTag] = useSearchParamState<string | null>({
defaultValue: null,
name: "tag",
revive: (value) => data.tagCounts?.find((t) => t[0] === value)?.[0],
});
const [, parentRoute] = useMatches();
invariant(parentRoute);
const userPageData = parentRoute.data as UserPageLoaderData;
@ -78,13 +100,17 @@ export default function UserArtPage() {
const hasBothArtMadeByAndMadeOf =
data.arts.some((a) => a.author) && data.arts.some((a) => !a.author);
const arts =
let arts =
type === "ALL" || !hasBothArtMadeByAndMadeOf
? data.arts
: type === "MADE-BY"
? data.arts.filter((a) => !a.author)
: data.arts.filter((a) => a.author);
if (filteredTag) {
arts = arts.filter((a) => a.tags?.includes(filteredTag));
}
return (
<div className="stack md">
<div className="stack horizontal justify-between items-start text-xs text-lighter">
@ -98,41 +124,63 @@ export default function UserArtPage() {
) : null}
</div>
{hasBothArtMadeByAndMadeOf ? (
<div className="stack md horizontal">
<div className="stack xs horizontal items-center">
<input
type="radio"
id="all"
checked={type === "ALL"}
onChange={() => setType("ALL")}
/>
<label htmlFor="all" className="mb-0">
{t("art:radios.all")}
</label>
</div>
<div className="stack xs horizontal items-center">
<input
type="radio"
id="made-by"
checked={type === "MADE-BY"}
onChange={() => setType("MADE-BY")}
/>
<label htmlFor="made-by" className="mb-0">
{t("art:radios.madeBy")}
</label>
</div>
<div className="stack xs horizontal items-center">
<input
type="radio"
id="made-of"
checked={type === "MADE-OF"}
onChange={() => setType("MADE-OF")}
/>
<label htmlFor="made-of" className="mb-0">
{t("art:radios.madeFor")}
</label>
</div>
{hasBothArtMadeByAndMadeOf || data.tagCounts ? (
<div className="stack md horizontal items-center flex-wrap">
{data.tagCounts ? (
<select
value={filteredTag ?? ALL_TAGS_KEY}
onChange={(e) =>
setFilteredTag(
e.target.value === ALL_TAGS_KEY ? null : e.target.value
)
}
className="w-max"
>
<option value={ALL_TAGS_KEY}>All ({data.arts.length})</option>
{data.tagCounts.map(([tag, count]) => (
<option key={tag} value={tag}>
#{tag} ({count})
</option>
))}
</select>
) : null}
{hasBothArtMadeByAndMadeOf ? (
<div className="stack md horizontal">
<div className="stack xs horizontal items-center">
<input
type="radio"
id="all"
checked={type === "ALL"}
onChange={() => setType("ALL")}
/>
<label htmlFor="all" className="mb-0">
{t("art:radios.all")}
</label>
</div>
<div className="stack xs horizontal items-center">
<input
type="radio"
id="made-by"
checked={type === "MADE-BY"}
onChange={() => setType("MADE-BY")}
/>
<label htmlFor="made-by" className="mb-0">
{t("art:radios.madeBy")}
</label>
</div>
<div className="stack xs horizontal items-center">
<input
type="radio"
id="made-of"
checked={type === "MADE-OF"}
onChange={() => setType("MADE-OF")}
/>
<label htmlFor="made-of" className="mb-0">
{t("art:radios.madeFor")}
</label>
</div>
</div>
) : null}
</div>
) : null}

View File

@ -1156,6 +1156,20 @@ dialog::backdrop {
outline: none;
}
.art__dialog__tag {
background-color: #fff;
border-radius: var(--rounded);
color: #000;
font-size: var(--fonts-xxs);
padding-inline: var(--s-1);
margin-block-start: var(--s-1);
margin-block-end: var(--s-0-5);
}
.art__dialog__tag__user {
background-color: var(--theme);
}
.art__dialog__description {
font-size: var(--fonts-sm);
text-align: center;
@ -1174,9 +1188,18 @@ dialog::backdrop {
font-size: var(--fonts-sm);
}
.art__delete-tag-button {
margin-block-start: -5px;
margin-inline-start: 1px;
}
.art__creation-mode-switcher-container {
height: 20px;
}
.pagination__dot {
width: 0.5rem;
height: 0.5rem;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background-color: var(--theme-transparent);
transition: all 0.2s ease;

View File

@ -90,7 +90,6 @@ export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
export const VODS_PAGE = "/vods";
export const LEADERBOARDS_PAGE = "/leaderboards";
export const LINKS_PAGE = "/links";
export const ART_PAGE = "/art";
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
@ -130,10 +129,11 @@ export const newVodPage = (vodToEditId?: number) =>
`${VODS_PAGE}/new${vodToEditId ? `?vod=${vodToEditId}` : ""}`;
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
`${userResultsPage(user)}/highlights`;
export const artPage = (tag?: string) => `/art${tag ? `?tag=${tag}` : ""}`;
export const userArtPage = (user: UserLinkArgs, source?: ArtSouce) =>
`${userPage(user)}/art${source ? `?source=${source}` : ""}`;
export const newArtPage = (artId?: Art["id"]) =>
`${ART_PAGE}/new${artId ? `?art=${artId}` : ""}`;
`${artPage()}/new${artId ? `?art=${artId}` : ""}`;
export const userNewBuildPage = (
user: UserLinkArgs,
params?: { weapon: MainWeaponId; build: BuildAbilitiesTupleWithUnknown }

View File

@ -0,0 +1,27 @@
module.exports.up = function (db) {
db.prepare(
/*sql*/ `
create table "ArtTag" (
"id" integer primary key,
"name" text unique not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
"authorId" integer not null,
foreign key ("authorId") references "User"("id") on delete restrict
) strict
`
).run();
db.prepare(
/*sql*/ `
create table "TaggedArt" (
"artId" integer not null,
"tagId" integer not null,
foreign key ("artId") references "Art"("id") on delete cascade,
foreign key ("tagId") references "ArtTag"("id") on delete cascade
) strict
`
).run();
db.prepare(`create index tagged_art_art_id on "TaggedArt"("artId")`).run();
db.prepare(`create index tagged_art_tag_id on "TaggedArt"("tagId")`).run();
};