Add the /donate page

Just doing some house-cleaning on easy pages that need converted before DTI Classic can retire!
This commit is contained in:
Emi Matchu 2022-09-25 08:05:38 -07:00
parent 2b486ea218
commit 07e2c0f7b1
9 changed files with 567 additions and 305 deletions

105
pages/donate.tsx Normal file
View file

@ -0,0 +1,105 @@
import DonationsPage from "../src/app/DonationsPage";
// @ts-ignore: doesn't understand module.exports
import connectToDb from "../src/server/db";
type Props = { campaigns: Campaign[] };
type Campaign = {
id: string;
name: string;
donationFeatures: DonationFeature[];
};
type DonationFeature = {
id: string;
donorName: string | null;
outfit: { id: string; name: string; updatedAt: string } | null;
};
export default function DonationsPageWrapper({ campaigns }: Props) {
return <DonationsPage campaigns={campaigns} />;
}
/**
* getStaticProps loads the donation info and organizes it into a convenient
* structure for the page props.
*
* This happens at build time! This data basically hasn't changed since 2017,
* so we'd rather have the performance benefits and reliability of just
* building it up-front than making this an SSR thing. (It also makes it harder
* for someone to add a surprise prank message years later when we're not
* paying attention, by editing the outfit titleit would still probably make
* it onto the site eventually, but not until the next build, which should be
* discouraging. But nobody's ever tried to prank this page before, so, shrug!)
*
* I also just went with a direct DB query, to avoid putting any of this in our
* GraphQL schema when it's really super-duper *just* for this page and *just*
* going to be requested in super-duper *one* way.
*/
export async function getStaticProps() {
const db = await connectToDb();
const [rows]: [QueryRow[]] = await db.query({
sql: `
SELECT
donation_features.id,
donations.donor_name,
campaigns.id, campaigns.name,
outfits.id, outfits.name, outfits.updated_at
FROM donation_features
INNER JOIN donations ON donations.id = donation_features.donation_id
INNER JOIN campaigns ON campaigns.id = donations.campaign_id
LEFT JOIN outfits ON outfits.id = donation_features.outfit_id
ORDER BY campaigns.created_at DESC, donations.created_at ASC;
`,
nestTables: true,
});
// Reorganize the query rows into campaign objects with lists of donation
// features.
const campaigns: Campaign[] = [];
for (const row of rows) {
// Find the campaign for this feature in our campaigns list, or add it if
// it's not present yet.
let campaign = campaigns.find((c) => c.id === String(row.campaigns.id));
if (campaign == null) {
campaign = {
id: String(row.campaigns.id),
name: row.campaigns.name,
donationFeatures: [],
};
campaigns.push(campaign);
}
// Reformat the outfit and donation feature into safer and more
// serializable forms.
const outfit =
row.outfits.id != null
? {
id: String(row.outfits.id),
name: row.outfits.name,
updatedAt: row.outfits.updated_at.toISOString(),
}
: null;
const donationFeature: DonationFeature = {
id: String(row.donation_features.id),
donorName: row.donations.donor_name,
outfit,
};
// Add this donation feature to the campaign.
campaign.donationFeatures.push(donationFeature);
}
return {
props: { campaigns },
};
}
type QueryRow = {
donation_features: { id: number };
donations: { donor_name: string | null };
campaigns: { id: string; name: string };
outfits:
| { id: number; name: string; updated_at: Date }
| { id: null; name: null; updated_at: null };
};

View file

@ -1,8 +1,11 @@
USE openneo_impress; USE openneo_impress;
-- Public data tables: read -- Public data tables: read
GRANT SELECT ON campaigns TO impress2020;
GRANT SELECT ON colors TO impress2020; GRANT SELECT ON colors TO impress2020;
GRANT SELECT ON color_translations TO impress2020; GRANT SELECT ON color_translations TO impress2020;
GRANT SELECT ON donation_features TO impress2020;
GRANT SELECT ON donations TO impress2020;
GRANT SELECT ON items TO impress2020; GRANT SELECT ON items TO impress2020;
GRANT SELECT ON item_translations TO impress2020; GRANT SELECT ON item_translations TO impress2020;
GRANT SELECT ON modeling_logs TO impress2020; GRANT SELECT ON modeling_logs TO impress2020;

127
src/app/DonationsPage.js Normal file
View file

@ -0,0 +1,127 @@
import React from "react";
import Head from "next/head";
import { Heading1, Heading2 } from "./util";
import TextContent from "./components/TextContent";
import FastlyLogoImg from "./images/fastly-logo.svg";
import { Box, Wrap, WrapItem } from "@chakra-ui/react";
import Image from "next/image";
import { OutfitCard } from "./UserOutfitsPage";
function DonationsPage({ campaigns }) {
return (
<>
<Head>
<title>Donations | Dress to Impress</title>
</Head>
<TextContent>
<Heading1 marginBottom="4">Our donors</Heading1>
<p>
Dress to Impress has been around a long timefrom back when Matchu was
in middle school! Without a real source of income, we used to depend a
lot on community donations to keep the site running.
</p>
<p>
Since then, life has changed a lot, and we're able to comfortably fund
Dress to Impress out-of-pocket. But we're still very grateful to the
donors who got us through those tougher years! Here's a showcase of
their outfits 💖
</p>
<p>
{/* Thanking Fastly somewhere in a sponsors page on our site is a
* condition of the program, so here we are! But also it's a great
* deal and I mean what I say! */}
We're also grateful to <FastlyLogoLink />, who offer us some CDN
hosting services under their non-profit &amp; open-source partner
program! They help us load all the big images around the site much
faster! Thank you!
</p>
</TextContent>
{campaigns.map((campaign) => (
<CampaignSection key={campaign.id} campaign={campaign} />
))}
</>
);
}
function CampaignSection({ campaign }) {
const { donationFeatures, name } = campaign;
const featuresWithNames = donationFeatures.filter(
(f) => f.donorName?.length > 0
);
const allDonorNames = new Set(featuresWithNames.map((f) => f.donorName));
const donorNamesWithOutfits = new Set(
featuresWithNames.filter((f) => f.outfit != null).map((f) => f.donorName)
);
const donorNamesWithoutOutfits = [...allDonorNames]
.filter((n) => !donorNamesWithOutfits.has(n))
.sort((a, b) => a.localeCompare(b));
return (
<Box marginBottom="8">
<Heading2 marginBottom="4">{name} donors</Heading2>
<Wrap spacing="4" justify="space-around">
{donationFeatures
.filter((f) => f.outfit != null)
.map((donationFeature) => (
<WrapItem key={donationFeature.outfit.id}>
<DonationOutfitCard
outfit={donationFeature.outfit}
donorName={donationFeature.donorName || "Anonymous"}
/>
</WrapItem>
))}
</Wrap>
{donorNamesWithoutOutfits.length > 0 && (
<Box
textAlign="center"
fontStyle="italic"
maxWidth="800px"
marginX="auto"
marginTop="8"
>
<strong>And a few more:</strong> {donorNamesWithoutOutfits.join(", ")}
. <strong>Thank you!</strong>
</Box>
)}
</Box>
);
}
function DonationOutfitCard({ outfit, donorName }) {
return (
<OutfitCard
outfit={outfit}
caption={
<Box>
<Box fontSize="sm">Thank you, {donorName}!</Box>
<Box fontSize="xs">{outfit.name}</Box>
</Box>
}
alt={`Outfit thumbnail: ${outfit.name}`}
/>
);
}
function FastlyLogoLink() {
return (
<a href="https://www.fastly.com/">
<Box
display="inline-block"
verticalAlign="middle"
filter="saturate(90%)"
marginBottom="-2px"
>
<Image
src={FastlyLogoImg}
width={40}
height={16}
alt="Fastly"
title="Fastly"
/>
</Box>
</a>
);
}
export default DonationsPage;

View file

@ -31,6 +31,9 @@ function GlobalFooter() {
<Link href="/privacy" passHref> <Link href="/privacy" passHref>
<ChakraLink>Privacy Policy (Sep 2022)</ChakraLink> <ChakraLink>Privacy Policy (Sep 2022)</ChakraLink>
</Link> </Link>
<Link href="/donate" passHref>
<ChakraLink>Donors</ChakraLink>
</Link>
<ChakraLink href={classicDTIUrl}>Classic DTI</ChakraLink> <ChakraLink href={classicDTIUrl}>Classic DTI</ChakraLink>
</HStack> </HStack>
<Box as="p" opacity="0.75"> <Box as="p" opacity="0.75">

View file

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { css } from "@emotion/react";
import { VStack } from "@chakra-ui/react"; import { VStack } from "@chakra-ui/react";
import { Heading1, Heading2, Heading3 } from "./util"; import { Heading1, Heading2, Heading3 } from "./util";
import { useAuthModeFeatureFlag } from "./components/useCurrentUser"; import { useAuthModeFeatureFlag } from "./components/useCurrentUser";
import Head from "next/head"; import Head from "next/head";
import TextContent from "./components/TextContent";
function PrivacyPolicyPage() { function PrivacyPolicyPage() {
const [authMode] = useAuthModeFeatureFlag(); const [authMode] = useAuthModeFeatureFlag();
@ -15,34 +15,18 @@ function PrivacyPolicyPage() {
<title>Privacy Policy | Dress to Impress</title> <title>Privacy Policy | Dress to Impress</title>
</Head> </Head>
<Heading1 marginBottom="4">Our privacy policy</Heading1> <Heading1 marginBottom="4">Our privacy policy</Heading1>
<VStack <TextContent maxWidth="800px">
spacing="4" <VStack spacing="4" alignItems="flex-start">
alignItems="flex-start"
css={css`
max-width: 800px;
p {
margin-bottom: 1em;
}
a {
text-decoration: underline;
}
h2,
h3 {
margin-bottom: 0.5em;
}
`}
>
<section> <section>
<p> <p>
Hi, friends! Dress to Impress collects certain personal data. Here's Hi, friends! Dress to Impress collects certain personal data.
how we use it! Here's how we use it!
</p> </p>
<p> <p>
First off, we'll <em>never</em> sell your private data, ever. It'll First off, we'll <em>never</em> sell your private data, ever.
only be available to you and our small trusted staffand we'll only It'll only be available to you and our small trusted staffand
use it to serve you directly, debug site issues, and help you share we'll only use it to serve you directly, debug site issues, and
your creations with others. help you share your creations with others.
</p> </p>
</section> </section>
{authMode === "auth0" && ( {authMode === "auth0" && (
@ -55,10 +39,10 @@ function PrivacyPolicyPage() {
account creation and login. account creation and login.
</p> </p>
<p> <p>
We made this decision because authentication is difficult to write We made this decision because authentication is difficult to
and maintain securely. We felt that Auth0 was the smoothest and write and maintain securely. We felt that Auth0 was the
most secure experience we could offer, especially as a small team smoothest and most secure experience we could offer, especially
of volunteers{" "} as a small team of volunteers{" "}
<span role="img" aria-label="Sweat smile emoji"> <span role="img" aria-label="Sweat smile emoji">
😅 😅
</span> </span>
@ -67,15 +51,15 @@ function PrivacyPolicyPage() {
<a href="https://auth0.com/legal/ss-tos"> <a href="https://auth0.com/legal/ss-tos">
Auth0's terms of service Auth0's terms of service
</a>{" "} </a>{" "}
commit to treating your user data as confidential information, not commit to treating your user data as confidential information,
to be shared with anyone else, and only to be used as part of not to be shared with anyone else, and only to be used as part
Dress to Impress. (The details are in Sections 6 and 7!) of Dress to Impress. (The details are in Sections 6 and 7!)
</p> </p>
<p> <p>
When signing up, Auth0 will ask for a username, password, and When signing up, Auth0 will ask for a username, password, and
email address. They store your password as a <em>hash</em> (which, email address. They store your password as a <em>hash</em>{" "}
colloquially, is like a one-way encryption), rather than as the (which, colloquially, is like a one-way encryption), rather than
plain password itself. as the plain password itself.
</p> </p>
<p> <p>
Some user accounts were created before we moved to Auth0. For Some user accounts were created before we moved to Auth0. For
@ -89,8 +73,8 @@ function PrivacyPolicyPage() {
<Heading2>Analytics and logging</Heading2> <Heading2>Analytics and logging</Heading2>
<p> <p>
To understand how people use our site, we use a service called{" "} To understand how people use our site, we use a service called{" "}
<a href="https://plausible.io/">Plausible</a>. Every time you visit <a href="https://plausible.io/">Plausible</a>. Every time you
a page, we send them a{" "} visit a page, we send them a{" "}
<a href="https://plausible.io/data-policy"> <a href="https://plausible.io/data-policy">
small packet of information small packet of information
</a> </a>
@ -105,29 +89,29 @@ function PrivacyPolicyPage() {
</a> </a>
</p> </p>
<p> <p>
We also use a service called <a href="https://sentry.io/">Sentry</a>{" "} We also use a service called{" "}
to track errors. When you encounter an error on our site, we send a <a href="https://sentry.io/">Sentry</a> to track errors. When you
copy of it to our Sentry account, to help us debug it later. This encounter an error on our site, we send a copy of it to our Sentry
might sometimes include personal data, but Sentry will only share it account, to help us debug it later. This might sometimes include
with us.{" "} personal data, but Sentry will only share it with us.{" "}
<a href="https://sentry.io/legal/dpa/2.0.0/"> <a href="https://sentry.io/legal/dpa/2.0.0/">
Here's their data policy. Here's their data policy.
</a> </a>
</p> </p>
<p> <p>
We also use <a href="https://www.linode.com/">Linode</a> and{" "} We also use <a href="https://www.linode.com/">Linode</a> and{" "}
<a href="https://www.fastly.com/">Fastly</a> for web hosting. Linode <a href="https://www.fastly.com/">Fastly</a> for web hosting.
stores our database, and handles most web traffic dealing with Linode stores our database, and handles most web traffic dealing
personal data. Personal data also travels through Fastly's servers with personal data. Personal data also travels through Fastly's
temporarily, but they only store aggregate usage logs for us, not servers temporarily, but they only store aggregate usage logs for
any personally-identifying data. us, not any personally-identifying data.
</p> </p>
</section> </section>
<section> <section>
<Heading2>Creations and contributions</Heading2> <Heading2>Creations and contributions</Heading2>
<p> <p>
People use Dress to Impress to create, share, and communicate! Some People use Dress to Impress to create, share, and communicate!
of these things are public, some are private, and some are Some of these things are public, some are private, and some are
configurable. configurable.
</p> </p>
<Heading3>Outfits</Heading3> <Heading3>Outfits</Heading3>
@ -140,8 +124,8 @@ function PrivacyPolicyPage() {
share outfits by URL without logging in. share outfits by URL without logging in.
</p> </p>
<p> <p>
When you save an outfit to your account, it's somewhat private, but When you save an outfit to your account, it's somewhat private,
somewhat public. but somewhat public.
</p> </p>
<p> <p>
It's private in the sense that there is no central place where It's private in the sense that there is no central place where
@ -155,7 +139,8 @@ function PrivacyPolicyPage() {
<p> <p>
We might change this in the future, to make the URLs hard to guess We might change this in the future, to make the URLs hard to guess
and <em>genuinely</em> private. Until then, we advise users to not and <em>genuinely</em> private. Until then, we advise users to not
to include sensitive data in the outfits they save to their account. to include sensitive data in the outfits they save to their
account.
</p> </p>
<Heading3>Item lists</Heading3> <Heading3>Item lists</Heading3>
<p> <p>
@ -163,8 +148,8 @@ function PrivacyPolicyPage() {
and want, by saving item lists to their account. and want, by saving item lists to their account.
</p> </p>
<p> <p>
These lists are private by default, but can be configured to either These lists are private by default, but can be configured to
be "public" or "trading" as well. either be "public" or "trading" as well.
</p> </p>
<p> <p>
The "public" status means that anyone who knows your Dress to The "public" status means that anyone who knows your Dress to
@ -182,9 +167,9 @@ function PrivacyPolicyPage() {
</p> </p>
<p> <p>
Sometimes, this will download new public outfit data that we've Sometimes, this will download new public outfit data that we've
never seen before. For example, you might show us a Draik (a species never seen before. For example, you might show us a Draik (a
of Neopet) wearing a new item, and we don't have data for a Draik species of Neopet) wearing a new item, and we don't have data for
wearing that item yet. a Draik wearing that item yet.
</p> </p>
<p> <p>
When that happens, we'll extract that specific piece of data from When that happens, we'll extract that specific piece of data from
@ -193,26 +178,27 @@ function PrivacyPolicyPage() {
"modeling". "modeling".
</p> </p>
<p> <p>
When you model new data for us, it's separated from your pet. Users When you model new data for us, it's separated from your pet.
can't discover what pet modeled a certain piece of data, or what Users can't discover what pet modeled a certain piece of data, or
else that pet was wearing. what else that pet was wearing.
</p> </p>
<p> <p>
But, if you're logged in when modeling, we'll publicly credit your But, if you're logged in when modeling, we'll publicly credit your
account for the new "contribution". This will appear in a number of account for the new "contribution". This will appear in a number
places, including a list of the most recent contributions, and it of places, including a list of the most recent contributions, and
will add points to your account that contribute to a public high it will add points to your account that contribute to a public
score list. This will publicly display your username. high score list. This will publicly display your username.
</p> </p>
<p> <p>
Right now, modeling contributions from logged-in users are always Right now, modeling contributions from logged-in users are always
public. This is a limitation of our system, and we might change it public. This is a limitation of our system, and we might change it
in the future! For now, if you would like to have your public in the future! For now, if you would like to have your public
contributions removed from the site, please use the contact link at contributions removed from the site, please use the contact link
the bottom of the page. at the bottom of the page.
</p> </p>
</section> </section>
</VStack> </VStack>
</TextContent>
</> </>
); );
} }

View file

@ -1,8 +1,8 @@
import { css } from "@emotion/react";
import { VStack } from "@chakra-ui/react"; import { VStack } from "@chakra-ui/react";
import Head from "next/head"; import Head from "next/head";
import { Heading1, Heading2 } from "./util"; import { Heading1, Heading2 } from "./util";
import TextContent from "./components/TextContent";
function TermsOfUsePage() { function TermsOfUsePage() {
return ( return (
@ -11,30 +11,14 @@ function TermsOfUsePage() {
<title>Terms of Use | Dress to Impres</title> <title>Terms of Use | Dress to Impres</title>
</Head> </Head>
<Heading1 marginBottom="4">Our terms of use</Heading1> <Heading1 marginBottom="4">Our terms of use</Heading1>
<VStack <TextContent maxWidth="800px">
spacing="4" <VStack spacing="4" alignItems="flex-start">
alignItems="flex-start"
css={css`
max-width: 800px;
p {
margin-bottom: 1em;
}
a {
text-decoration: underline;
}
h2,
h3 {
margin-bottom: 0.5em;
}
`}
>
<section> <section>
<p> <p>
Hi, friends! Here's some information about how Dress to Impress is Hi, friends! Here's some information about how Dress to Impress is
meant to be used. The rules here aren't very formal, but we hope meant to be used. The rules here aren't very formal, but we hope
they're clear, and we take them very seriously. Thank you for taking they're clear, and we take them very seriously. Thank you for
the time to read! taking the time to read!
</p> </p>
</section> </section>
<section> <section>
@ -58,67 +42,69 @@ function TermsOfUsePage() {
<section> <section>
<Heading2>What you can post on this service</Heading2> <Heading2>What you can post on this service</Heading2>
<p> <p>
<strong>Keep it Neoboard-safe.</strong> Neopets.com allows links to <strong>Keep it Neoboard-safe.</strong> Neopets.com allows links
Dress to Impress, so everything needs to be safe for Neopians of all to Dress to Impress, so everything needs to be safe for Neopians
ages! Please keep all content "PG" and appropriate for young of all ages! Please keep all content "PG" and appropriate for
community members, just like you do on Neopets.com. (That said, the young community members, just like you do on Neopets.com. (That
rules on the Neoboards haven't always been morally right, such as said, the rules on the Neoboards haven't always been morally
when LGBTQIA+ discussion was banned. We'll always diverge from those right, such as when LGBTQIA+ discussion was banned. We'll always
rules when it's ethically appropriate!) diverge from those rules when it's ethically appropriate!)
</p> </p>
<p> <p>
<strong>Don't sell things for real money here.</strong> We don't <strong>Don't sell things for real money here.</strong> We don't
have the capacity to validate who is and isn't a legitimate seller, have the capacity to validate who is and isn't a legitimate
so we err on the side of safety and ban <em>all</em> sales. If seller, so we err on the side of safety and ban <em>all</em>{" "}
you're selling something, please do it in a community where trust sales. If you're selling something, please do it in a community
and reputation can be managed more appropriately, and please make where trust and reputation can be managed more appropriately, and
sure it's in line with Neopets's terms. please make sure it's in line with Neopets's terms.
</p> </p>
</section> </section>
<section> <section>
<Heading2>How you can use our data</Heading2> <Heading2>How you can use our data</Heading2>
<p> <p>
<strong>Be thoughtful using Neopets's data.</strong> While Dress to <strong>Be thoughtful using Neopets's data.</strong> While Dress
Impress has a license to distribute Neopets data and images, we to Impress has a license to distribute Neopets data and images, we
aren't authorized to extend all of the same permissions to you. aren't authorized to extend all of the same permissions to you.
Please think carefully about how you use Neopets's art and data you Please think carefully about how you use Neopets's art and data
find on this site, and make sure you're complying with their you find on this site, and make sure you're complying with their
licensing agreements and fair use laws, especially for derived works licensing agreements and fair use laws, especially for derived
like outfits. But personal use, and usage that stays on our site, works like outfits. But personal use, and usage that stays on our
are always okay! site, are always okay!
</p> </p>
<p> <p>
<strong>Be thoughtful using user-generated data.</strong> Some data <strong>Be thoughtful using user-generated data.</strong> Some
posted to Dress to Impress is generated by our users, like their data posted to Dress to Impress is generated by our users, like
outfits and item lists. When you post those to Dress to Impress, you their outfits and item lists. When you post those to Dress to
grant us a license to redistribute them with attribution as part of Impress, you grant us a license to redistribute them with
the site's functionality, respecting your privacy settings when attribution as part of the site's functionality, respecting your
applicable. But each user still owns their own creations, so only privacy settings when applicable. But each user still owns their
they can grant you permission to use or share it yourself. own creations, so only they can grant you permission to use or
share it yourself.
</p> </p>
<p> <p>
<strong>Please reach out before using our APIs!</strong> If you'd <strong>Please reach out before using our APIs!</strong> If you'd
like to use our data to build something new, please contact us! We'd like to use our data to build something new, please contact us!
like to help if we can. But please don't use our APIs without We'd like to help if we can. But please don't use our APIs without
talking to us first: it can cause performance issues for us, and talking to us first: it can cause performance issues for us, and
reliability issues for you. But we have a few folks who use Dress to reliability issues for you. But we have a few folks who use Dress
Impress for things like Discord bots, and we'd like to support you to Impress for things like Discord bots, and we'd like to support
and your community too! you and your community too!
</p> </p>
</section> </section>
<section> <section>
<Heading2>Warranty and liability</Heading2> <Heading2>Warranty and liability</Heading2>
<p> <p>
<strong>Our data won't always be correct.</strong> While we do our <strong>Our data won't always be correct.</strong> While we do our
best to keep the customization on our site in sync with Neopets.com, best to keep the customization on our site in sync with
sometimes our data is out-of-date, and sometimes an item looks Neopets.com, sometimes our data is out-of-date, and sometimes an
different on our site than on Neopets.com. We're glad to be a item looks different on our site than on Neopets.com. We're glad
resource for users buying Neocash items, but as an unofficial to be a resource for users buying Neocash items, but as an
service we simply can't make guarantees, and we encourage you to unofficial service we simply can't make guarantees, and we
check other sources before making a purchase. encourage you to check other sources before making a purchase.
</p> </p>
</section> </section>
</VStack> </VStack>
</TextContent>
</> </>
); );
} }

View file

@ -139,14 +139,14 @@ function UserOutfitsPageContent() {
); );
} }
function OutfitCard({ outfit }) { export function OutfitCard({ outfit, caption = null, alt = null }) {
const image = ( const image = (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<OutfitThumbnail <OutfitThumbnail
outfitId={outfit.id} outfitId={outfit.id}
updatedAt={outfit.updatedAt} updatedAt={outfit.updatedAt}
alt={buildOutfitAltText(outfit)} alt={alt ?? buildOutfitAltText(outfit)}
// Firefox shows alt text as a fallback for images it can't show yet. // Firefox shows alt text as a fallback for images it can't show yet.
// Show our alt text clearly if the image failed to load... but hide // Show our alt text clearly if the image failed to load... but hide
// it if it's still loading. It's normal for these to take a second // it if it's still loading. It's normal for these to take a second
@ -184,7 +184,7 @@ function OutfitCard({ outfit }) {
outline: "none", outline: "none",
}} }}
> >
<OutfitCardLayout image={image} caption={outfit.name} /> <OutfitCardLayout image={image} caption={caption ?? outfit.name} />
</Box> </Box>
</Link> </Link>
); );

View file

@ -0,0 +1,31 @@
import React from "react";
import { Box } from "@chakra-ui/react";
import { css } from "@emotion/react";
/**
* TextContent is a wrapper for just, like, normal chill text-based content!
* Its children will receive some simple CSS for tags like <p>, <a>, etc.
*/
function TextContent({ children, ...props }) {
return (
<Box
{...props}
css={css`
p {
margin-bottom: 1em;
}
a {
text-decoration: underline;
}
h2,
h3 {
margin-bottom: 0.5em;
}
`}
>
{children}
</Box>
);
}
export default TextContent;

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg278"
version="1.1"
width="1024"
height="411"
viewBox="-18.0552558 -18.0552558 1590.2355116 637.9523716">
<defs
id="defs275" />
<path
style="fill:#ff282d;fill-opacity:1"
id="polygon204"
d="m 871.9707,0 v 363.35742 c 0,71.341 17.60489,103.9004 94.3379,103.9004 18.173,0 43.145,-4.67685 61.998,-8.71485 l -12.1269,-72.81836 c -12.759,2.692 -23.9153,2.36741 -31.9883,2.56641 -33.557,0.825 -30.6582,-10.20375 -30.6582,-41.84375 V 209.67188 h 63.8731 V 149.06055 H 953.5332 V 0 Z m 267.25,0 -81.5625,0.0117 0.012,61.75391 v 405.49414 h 121.8008 v -61.95703 h -40.25 z M 139.89062,0.002 c -84.128,0 -98.48632,28.62866 -98.48632,94.59765 v 54.46289 L 0,155.87114 v 53.80078 H 41.4043 V 405.30274 H 0.00195 v 61.96484 l 163.94141,-0.0176 V 405.30469 H 122.96875 V 209.67188 h 61.54102 v -60.61133 h -61.54102 v -47.13086 c 0,-28.959 7.49381,-31.91602 37.13281,-31.91602 8.072,0 14.40774,0.35982 27.17774,2.38282 L 198.45508,6.08203 c -18.856,-3.397 -40.39445,-6.08008 -58.56446,-6.08008 z m 171.1504,85.85546 v 20.71485 h 9.94921 v 28.16601 h 0.56641 c -78.009,14.361 -137.12695,82.67004 -137.12695,164.83204 0,92.595 75.0632,167.6582 167.6582,167.6582 31.602,0 61.15477,-8.75508 86.38477,-23.95508 l 14.69336,23.98633 h 86.15234 v -81.53516 h -19.49414 l -0.0547,-253.81055 h -81.56055 v 23.80274 c -16.799,-10.078 -35.51107,-17.28452 -55.45507,-20.97852 h 0.46093 v -28.16601 h 9.95118 V 85.85746 Z m 391.59179,45.52735 c -76.571,0 -122.55078,26.96884 -122.55078,95.71484 0,72.629 56.74414,97.03886 128.24414,109.25586 37.951,7.696 49.5625,15.4097 49.5625,31.4707 0,11.573 -6.55011,33.33985 -47.03711,33.33985 -14.549,0 -42.41709,0.51486 -68.99609,-4.61914 l 0.0352,-10.82227 H 580.127 v 60.91016 c 37.934,9.626 87.64872,20.625 138.88672,20.625 76.57,0 112.94727,-36.51722 112.94727,-107.19922 0,-74.531 -60.36566,-88.60952 -120.47266,-102.10352 -41.121,-8.991 -46.38867,-17.33743 -46.38867,-32.77343 0,-10.911 4.62894,-31.03907 42.58594,-31.03907 13.292,0 37.7957,0.0635 62.4707,4.56446 v 10.85156 h 61.74805 v -60.5 c -37.954,-9.631 -77.39649,-17.67578 -129.27149,-17.67578 z m 683.26949,17.67578 v 60.55469 h 40.1211 l -59.2246,145.70117 -59.2226,-145.70117 h 40.2051 v -60.55274 h -168.3106 v 60.55274 h 40.2109 l 104.5371,257.23242 c -12.101,37.004 -50.7927,58.25781 -85.0937,58.25781 -7.404,0 -21.5161,-1.3268 -32.2891,-3.3418 l -7.3769,74.02735 c 16.789,4.034 40.9995,6.05078 59.1855,6.05078 73.966,0 122.5326,-67.1082 152.1016,-138.4082 L 1513.916,209.61332 h 40.209 V 149.06055 Z M 356.97266,213.22852 c 42.935,2.295 77.52081,35.97276 81.25781,78.50976 v 2.80274 h -9.79102 v 9.77148 h 9.79297 v 2.67578 c -3.717,42.557 -38.31076,76.25578 -81.25976,78.55078 v -9.58789 h -9.77149 v 9.5625 c -43.775,-2.551 -78.79022,-37.72236 -81.07422,-81.56835 h 9.71289 v -9.77149 h -9.66211 c 2.596,-43.542 37.46644,-78.37701 81.02344,-80.91601 v 9.60937 h 9.77149 z m 31.71679,43.66211 -31.52343,27.46875 c -1.591,-0.57 -3.29232,-0.89649 -5.07032,-0.89649 -8.506,0 -15.39257,7.08922 -15.39258,15.82422 0,8.745 6.88658,15.83203 15.39258,15.83203 8.502,0 15.4043,-7.08703 15.4043,-15.83203 0,-1.659 -0.25189,-3.25676 -0.71289,-4.75976 l 28.10742,-31.42774 z M 1522.8594,405.63672 c -17.14,0 -30.9404,13.62458 -30.9414,30.76758 0,17.138 13.8004,30.75976 30.9414,30.75976 17.14,0 31.0234,-13.62176 31.0234,-30.75976 0,-17.143 -13.8824,-30.76758 -31.0234,-30.76758 z m 0,5.1875 c 14.239,0 25.8379,11.33508 25.8379,25.58008 0,14.237 -11.5989,25.92383 -25.8379,25.92383 -14.236,0 -25.7568,-11.68783 -25.7578,-25.92383 0,-14.244 11.5218,-25.58008 25.7578,-25.58008 z m -11.7754,10.45508 v 30.23828 h 6.9414 v -9.14062 h 4.3047 l 6.2422,9.14062 h 8.5254 l -7.5567,-10.37305 c 3.864,-1.14 6.3262,-4.48443 6.3262,-9.39843 0,-6.686 -4.6598,-10.4668 -12.1328,-10.4668 z m 6.9414,6.06641 h 5.709 c 2.995,0 5.1035,1.23734 5.1035,4.40234 0,3.334 -2.1074,4.5625 -5.2754,4.5625 h -5.5371 z"
clip-path="none"
mask="none" />
</svg>
<!-- version: 20171223, original size: 1554.125 601.84186, border: 3% -->

After

Width:  |  Height:  |  Size: 4.2 KiB