mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 07:32:19 -05:00
Art tags (#1420)
* 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:
parent
172e251c97
commit
7b18a0cfc7
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
14
app/features/art/queries/allArtTags.server.ts
Normal file
14
app/features/art/queries/allArtTags.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
27
migrations/031-art-tags.js
Normal file
27
migrations/031-art-tags.js
Normal 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();
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user