diff --git a/app/(content)/community-guidelines/page.tsx b/app/(content)/community-guidelines/page.tsx new file mode 100644 index 0000000..5af6edb --- /dev/null +++ b/app/(content)/community-guidelines/page.tsx @@ -0,0 +1,454 @@ +import classNames from "classnames"; +import Link from "next/link"; +import Center from "../../../components/Center"; + +import { cookies } from "next/headers"; +import { getUser, sessionTokenCookieName } from "../../../lib/getUser"; + +export default async function CommunityGuidelinesPage() { + const token = cookies().get(sessionTokenCookieName)?.value; + const user = await getUser(token); + + const age: number | null = user?.birthdayMonth + ? (Date.now() - user.birthdayMonth.getTime()) / 1000 / 60 / 60 / 24 / 364.25 + : null; + + const ageRange = age ? (age < 16 ? "13+" : age < 18 ? "16+" : "18+") : null; + + return ( +
+
+

Community Guidelines

+

+ Everyone wants to have a good experience online. These community + guidelines describe the kinds of behaviors we expect in our community + so that we can create a positive environment for everybody. +

+ +
+
+

+ Common Decency +

+
+

+ We welcome everyone into our community, regardless of their + background or identity, and we respect the privacy and safety of + others. +

+
+
+
+

+ Community Culture +

+
+

+ We are a community that embraces learning, supports each other, + and disagrees gracefully. +

+

We represent the best of human ingenuity and collaboration.

+
+
+
+

+ Age-Appropriate Safety +

+
+

Everything we share is appropriate for anyone 13+.

+

+ We are careful about sharing our personal information. Younger + users are required to take extra precaution. +

+
+
+
+ + Common Decency +
+ + +
+ + Community Culture +
+ + + +
+ + Age-Appropriate Safety +
+ + +

+ For your own safety, you are{" "} + required to be cautious + about the personal information you share online. This chart shows + what you are allowed to share depending on your age. +

+
+
+ {/* -m-px on table to hide the cell border so that it is covered by the div border */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 13+ + {ageRange === "13+" && ( +
+ {user!.username} +
+ )} +
+ 16+ + {ageRange === "16+" && ( +
+ {user!.username} +
+ )} +
+ 18+ + {ageRange === "18+" && ( +
+ {user!.username} +
+ )} +
+ Full Name +
+ Birthday +
+ Email +
+ Phone number +
+ Face or voice +
+ Precise location +
+
+
+

+ Please keep in mind that even if you are allowed to share + something, that doesn't necessarily mean it's a good idea. Take + caution to protect yourself. The moderation team will do their + best to help assist you with this, but it is ultimately your + responsibility. +

+
+
+
+
+ ); +} + +interface SectionHeaderProps { + children: React.ReactNode; +} + +function SectionHeader({ children }: SectionHeaderProps) { + return ( +

{children}

+ ); +} + +interface BooleanCellProps { + checked: boolean; +} + +function BooleanCell({ checked }: BooleanCellProps) { + return ( + + {checked ? ( + + + + ) : ( + + + + )} + + ); +} + +interface GuidelinePointProps { + title: string; + positives?: string[]; + negatives?: string[]; + children?: React.ReactNode; + className?: string; +} + +function GuidelinePoint({ + title, + positives, + negatives, + children, + className, +}: GuidelinePointProps) { + return ( +
+

+ {title} +

+ {children ?? ( +
+
+ {positives && ( + <> +
+ + + + + How you can help + +
+ +
    + {positives.map((str) => ( +
  • {str}
  • + ))} +
+ + )} +
+
+ {negatives && ( + <> +
+ + + + + Not allowed in our community + +
+ +
    + {negatives.map((str) => ( +
  • {str}
  • + ))} +
+ + )} +
+
+ )} +
+ ); +} + +interface GuidelineSubpointProps { + id: string; + type: "positive" | "negative"; + children: React.ReactNode; +} + +function GuidelineSubpoint({ id, type, children }: GuidelineSubpointProps) { + return ( +
+
+ + {type === "positive" ? ( + + ) : ( + + )} + + + + {type === "positive" + ? "How you can help" + : "Not allowed in our community"} + + + {id && ( + + #{id} + + )} +
+
+

{children}

+
+
+ ); +} diff --git a/app/help/publish-with-vercel/page.tsx b/app/(content)/help/publish-with-vercel/page.tsx similarity index 97% rename from app/help/publish-with-vercel/page.tsx rename to app/(content)/help/publish-with-vercel/page.tsx index e4d5da9..666a78d 100644 --- a/app/help/publish-with-vercel/page.tsx +++ b/app/(content)/help/publish-with-vercel/page.tsx @@ -1,5 +1,4 @@ -import Center from "../../../components/Center"; -import Nav, { NavSpace } from "../../../components/Nav"; +import Center from "../../../../components/Center"; export const metadata = { title: "Publish your project as a website", @@ -8,10 +7,6 @@ export const metadata = { export default function PublishWithVercel() { return ( <> -
-
-

Publish your project as a website! diff --git a/app/(content)/layout.tsx b/app/(content)/layout.tsx new file mode 100644 index 0000000..afe5bcd --- /dev/null +++ b/app/(content)/layout.tsx @@ -0,0 +1,19 @@ +import classNames from "classnames"; +import { Footer } from "../../components/Footer"; +import Nav from "../../components/Nav"; +import TopBorder from "../../components/TopBorder"; + +interface ContentLayoutProps { + children: React.ReactNode; +} + +export default function ContentLayout({ children }: ContentLayoutProps) { + return ( +
+ +
+ ); +} diff --git a/app/(content)/mystuff/page.tsx b/app/(content)/mystuff/page.tsx new file mode 100644 index 0000000..8b5354d --- /dev/null +++ b/app/(content)/mystuff/page.tsx @@ -0,0 +1,358 @@ +import { cookies } from "next/headers"; +import { getUser, sessionTokenCookieName } from "../../../lib/getUser"; +import { redirect } from "next/navigation"; + +import prisma from "../../../lib/prisma"; + +import { relativeDateStr } from "../../../lib/fuzzyDate"; +import Link from "next/link"; +import { changeProjectShared } from "../../../actions/changeProjectShared"; +import { UnscrapableEmailLink } from "../../../components/UnscrapableEmailLink"; +import { + getUserCurrentFilesSize, + USER_SIZE_LIMIT, +} from "../../../lib/sizeLimits"; +import classNames from "classnames"; +import { PatreonIcon } from "../../../components/icons/Patreon"; + +export const metadata = { + title: "My Stuff", +}; + +export default async function MyStuff() { + const token = cookies().get(sessionTokenCookieName)?.value; + const user = await getUser(token); + + if (!user) { + return redirect("/"); + } + + const totalBytes = await getUserCurrentFilesSize(user.id); + + let totalBytesRange = "normal"; + if (totalBytes >= USER_SIZE_LIMIT * 0.8) { + totalBytesRange = "warning"; + } + if (totalBytes >= USER_SIZE_LIMIT * 0.9) { + totalBytesRange = "high"; + } + + const projects = await prisma.project.findMany({ + where: { + ownerId: user.id, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const projectSizes = await prisma.file.groupBy({ + by: ["projectId"], + where: { + projectId: { + in: projects.map((project) => project.id), + }, + }, + _sum: { + size: true, + }, + }); + + const getProjectSize = (id: string) => { + const projectSize = projectSizes.find( + (projectSize) => projectSize.projectId === id, + ); + + return projectSize?._sum.size ?? 0; + }; + + return ( +
+
+

My Stuff

+
+ {/* Main Content */} +
+

Projects

+
+ {projects.length === 0 ? ( +
+ + + + + + + + + + + + + + + + + + + + + + + + +

+ + Power up your programming! + +

+

+ Begin by{" "} + + creating your first Leopard project + + . +

+
+ ) : ( +
+ {projects.map((project) => ( +
+
+
+ + {project.title} + + {project.scratchProjectId && ( + <> + {" "} + ( + + Scratch + + ) + + )} +
+
+ {relativeDateStr(project.createdAt)} -{" "} + + {formatBytes(getProjectSize(project.id), "mb")} + +
+
+ +
+ + + {project.shared ? ( + + ) : ( + + )} +
+
+ ))} +
+ )} +
+
+ + {/* Sidebar */} +
+

Account Limits

+
+
+ My Storage +
+
+ + {formatBytes(totalBytes, "mb")} + {" "} + of {formatBytes(USER_SIZE_LIMIT, "mb")} used +
+
+
+
+
+ +

+ Why is my storage limited? +

+
+

+ I am providing Leopard free of charge because I believe + everybody should have an equal opportunity to learn JavaScript. +

+

+ But storing projects costs money. Leopard can't currently afford + to provide more free storage. +

+
+

+ How can I support Leopard? +

+
+

+ If you would like to support Leopard development and hosting + costs, I would greatly appreciate a{" "} + + Patreon donation + + . +

+ + + Support Leopard + +
+

+ How can I get more storage? +

+
+

+ If you've run out of storage space and do not wish to delete any + projects,{" "} + + contact me + {" "} + and I'll see what I can do. +

+
+
+
+
+
+ ); +} + +const NumberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + useGrouping: true, +}); + +function formatBytes(bytes: number, unit: "bytes" | "mb" = "mb") { + switch (unit) { + case "bytes": { + let result = NumberFormatter.format(bytes); + result += " byte"; + if (bytes !== 1) { + result += "s"; + } + return result; + } + case "mb": { + let result = NumberFormatter.format(bytes / 1_000_000); + result += " MB"; + return result; + } + } +} diff --git a/app/page.tsx b/app/(content)/page.tsx similarity index 69% rename from app/page.tsx rename to app/(content)/page.tsx index f07fcc3..25621f5 100644 --- a/app/page.tsx +++ b/app/(content)/page.tsx @@ -2,10 +2,9 @@ import { useRef } from "react"; import Link from "next/link"; -import Center from "../components/Center"; -import ConvertBox from "../components/ConvertBox"; -import Nav, { NavSpace } from "../components/Nav"; -import { PatreonIcon } from "../components/icons/Patreon"; +import Center from "../../components/Center"; +import ConvertBox from "../../components/ConvertBox"; +import { PatreonIcon } from "../../components/icons/Patreon"; import { sponsors } from "./sponsors/sponsors"; export default function Index() { @@ -20,10 +19,6 @@ export default function Index() { return ( <> -
-
-
@@ -155,78 +150,6 @@ export default function Index() {
- -
-
-
-
- - Leopard - -

- Thank you to all the contributors who make this project - possible! -

-
-
-
Community
-
    -
  • - - Community Guidelines - -
  • -
  • - - Privacy Policy - -
  • -
-
-
-
Source Code
- -
-
-
-
); } diff --git a/app/(content)/privacy-policy/page.tsx b/app/(content)/privacy-policy/page.tsx new file mode 100644 index 0000000..dd028bc --- /dev/null +++ b/app/(content)/privacy-policy/page.tsx @@ -0,0 +1,196 @@ +import Center from "../../../components/Center"; +import { UnscrapableEmailLink } from "../../../components/UnscrapableEmailLink"; + +export default function PrivacyPolicyPage() { + return ( +
+
+

Privacy Policy

+
+ Last updated: +
+
+
+ + + + +
+

Leopard protects your privacy.

+

+ We don't want your private information. No, + really. We only store what is absolutely necessary. Anything + else is a safety risk for you and a liability for us. +

+

+ We provide complete transparency. The policy + below describes exactly what we gather and why, and our code is + open source so you can see 100% of what we do. +

+

+ We fund Leopard through donations. Leopard + isn't in the business of selling your personal information. We + bring in money when people trust us and want to support our + work, so our incentives are aligned with yours. +

+
+
+ +
+
+ + + + +

+ Questions or concerns? If you have questions or + comments about this privacy policy, you can{" "} + + email us + + . +

+
+
+

+ This privacy policy for Leopard ("we", "us", "our") describes + how and why we collect, store, and/or share ("gather", "use", + "collect", "process") your information when you use our services + ("Leopard"), such as when you visit our website at + leopardjs.com. +

+

+ If you do not agree with our policies and practices, please do + not use Leopard. +

+
+
+ +

What information does Leopard gather?

+

+ To protect your privacy, Leopard avoids gathering unnecessary + information about you. We only collect personal information that you + provide to us directly, and we only collect what we absolutely need + to make the website function. +

+
+
+

Public information

+

+ This information will appear publicly on Leopard. Only submit + things that can be seen by everyone. +

+
    +
  • + Your username: Everyone can see this. It is + used to identify you. +
  • +
  • + Projects you create: Projects you create and + share are visible to the whole world. We do our best to ensure + that your private projects are visible only to you, but we do + rely on external providers to host your content. +
  • +
  • + Comments and messages you send: Your comments + and messages on Leopard are public to the whole world. +
  • +
  • + Public interactions: When you interact with + users or content on Leopard, other people may see those + interactions. +
  • +
+
+
+

Private information

+

+ This information will never be shared. However, it may be used + by the Leopard core team for the purposes of making Leopard work + or responding to your requests. +

+
    +
  • + Email addresses: Used to verify your identity + (which helps prevent spam) and contact you +
  • +
  • + Password: Securely hashed so that nobody has + access to your password, including us. +
  • +
  • + Birthday month & year: Used to provide + age-appropriate safety features. +
  • +
+
+
+

Why is Leopard allowed to use my information?

+

+ Using information is only legal if it done for legitimate reasons. + Leopard's legal basis for most information processing is called + "legitimate interest", which means that we are using your + information responsibly, in ways that you would reasonably expect + based on the service we perform. +

+

+ Leopard also reserves the right to use your information if you + explicitly opt-in or if we are required to by law. +

+

Does Leopard share my information?

+

+ No. Some of your information appears publicly on the website, as + described above, but we do not share any of your private information + with anyone outside of our core team (as described in the "private + information" section above). +

+

+ Like the vast majority of online services, we do use third party + providers to store your information. We store almost all of your + information on servers provided by Amazon Web Services, and our web + traffic is routed through Vercel. +

+ +

What are my rights? How can I have my information deleted?

+

+ If you wish, you can{" "} + + contact us + {" "} + to request a copy of your information or to request that your + information be deleted. +

+

+ Anything you permanently delete on Leopard is immediately removed + from our database. However, we may retain backups of our database + for up to 30 days for the purposes of disaster recovery. +

+ +

Updates to this policy

+

+ We may update this privacy policy from time to time. The updated + version will be indicated by the "last updated" date at the top of + the policy. +

+

+ If we make material changes to this privacy policy, we will notify + you either by prominantly displaying a notice on the Leopard website + or by contacting you directly via email. +

+
+
+
+ ); +} diff --git a/app/(content)/settings/page.tsx b/app/(content)/settings/page.tsx new file mode 100644 index 0000000..f6d221c --- /dev/null +++ b/app/(content)/settings/page.tsx @@ -0,0 +1,47 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import Center from "../../../components/Center"; +import { SettingsForm } from "../../../components/SettingsForm"; +import { UnscrapableEmailLink } from "../../../components/UnscrapableEmailLink"; +import { getUser, sessionTokenCookieName } from "../../../lib/getUser"; + +export default async function SettingsPage() { + const token = cookies().get(sessionTokenCookieName)?.value; + const user = await getUser(token); + + if (!user) { + return redirect("/"); + } + + return ( +
+
+

Settings

+
+ + Many profile settings are not yet editable. + {" "} + If you need something changed,{" "} + + contact me + {" "} + and I'll take care of it for you. +
+
+

Profile

+ +
+
+
+ ); +} diff --git a/app/(content)/sponsors/page.tsx b/app/(content)/sponsors/page.tsx new file mode 100644 index 0000000..fa321aa --- /dev/null +++ b/app/(content)/sponsors/page.tsx @@ -0,0 +1,96 @@ +import Image, { type StaticImageData } from "next/image"; +import Link from "next/link"; +import Center from "../../../components/Center"; +import { UnscrapableEmailLink } from "../../../components/UnscrapableEmailLink"; +import { sponsors } from "./sponsors"; + +export default function SponsorsPage() { + return ( +
+
+

Sponsors

+

+ Leopard is made possible by generous support from the following + sponsors and donors. If you would like to support Leopard, please + consider{" "} + + donating on Patreon + {" "} + or{" "} + + reaching out + {" "} + about sponsorship. +

+ +
+ {sponsors.map((sponsor) => ( + + {sponsor.description} + + ))} +
+
+
+ ); +} + +interface SponsorCardProps { + name: string; + url: string; + logo: StaticImageData; + cover: StaticImageData; + children: React.ReactNode; + supportDescription?: React.ReactNode; +} + +function SponsorCard({ + name, + url, + logo, + cover, + children, + supportDescription, +}: SponsorCardProps) { + return ( +
+ +
+
+ {`${name} +
+

{name}

+
{children}
+
+ + Visit website + +
+
+ {supportDescription && ( +
+
+ {supportDescription} +
+
+ )} +
+ ); +} diff --git a/app/sponsors/sponsors.tsx b/app/(content)/sponsors/sponsors.tsx similarity index 94% rename from app/sponsors/sponsors.tsx rename to app/(content)/sponsors/sponsors.tsx index 99340b3..00483e6 100644 --- a/app/sponsors/sponsors.tsx +++ b/app/(content)/sponsors/sponsors.tsx @@ -1,7 +1,7 @@ import type { StaticImageData } from "next/image"; -import BXCodingCover from "../../public/sponsors/bxcoding/bxcoding-cover.jpeg"; -import BXCodingColorLogo from "../../public/sponsors/bxcoding/bxcoding-logo.png"; +import BXCodingCover from "../../../public/sponsors/bxcoding/bxcoding-cover.jpeg"; +import BXCodingColorLogo from "../../../public/sponsors/bxcoding/bxcoding-logo.png"; interface Sponsor { name: string; diff --git a/app/users/[username]/page.tsx b/app/(content)/users/[username]/page.tsx similarity index 94% rename from app/users/[username]/page.tsx rename to app/(content)/users/[username]/page.tsx index 6683624..6ac5afd 100644 --- a/app/users/[username]/page.tsx +++ b/app/(content)/users/[username]/page.tsx @@ -1,17 +1,16 @@ import classNames from "classnames"; import Image from "next/image"; -import DefaultProfilePicture from "../../../public/default-profile-picture.svg"; +import DefaultProfilePicture from "../../../../public/default-profile-picture.svg"; import Link from "next/link"; import { Metadata, ResolvingMetadata } from "next"; -import Nav, { NavSpace } from "../../../components/Nav"; -import prisma from "../../../lib/prisma"; -import { relativeDateStr } from "../../../lib/fuzzyDate"; +import prisma from "../../../../lib/prisma"; +import { relativeDateStr } from "../../../../lib/fuzzyDate"; import { cookies } from "next/headers"; -import { getUser, sessionTokenCookieName } from "../../../lib/getUser"; +import { getUser, sessionTokenCookieName } from "../../../../lib/getUser"; interface Props { params: { @@ -59,10 +58,6 @@ export default async function ProfilePage({ params: { username } }: Props) { return ( <> -
-
-
diff --git a/app/verify-email/page.tsx b/app/(content)/verify-email/page.tsx similarity index 96% rename from app/verify-email/page.tsx rename to app/(content)/verify-email/page.tsx index 15a0e57..fe32864 100644 --- a/app/verify-email/page.tsx +++ b/app/(content)/verify-email/page.tsx @@ -1,8 +1,7 @@ -import Center from "../../components/Center"; -import Nav from "../../components/Nav"; +import Center from "../../../components/Center"; import { notFound } from "next/navigation"; -import prisma from "../../lib/prisma"; +import prisma from "../../../lib/prisma"; import Link from "next/link"; import classNames from "classnames"; @@ -30,9 +29,6 @@ export default async function VerifyEmailPage({ return ( <> -
-
{updateEmail ? (
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 2f90910..73cff64 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,4 +1,5 @@ import Nav from "../../components/Nav"; +import TopBorder from "../../components/TopBorder"; interface AdminLayoutProps { children: React.ReactNode; @@ -6,11 +7,12 @@ interface AdminLayoutProps { export default function AdminLayout({ children }: AdminLayoutProps) { return ( - <> -
-
+ +
); } diff --git a/lib/useWindowScrollPosition.tsx b/lib/useWindowScrollPosition.tsx new file mode 100644 index 0000000..a291ad9 --- /dev/null +++ b/lib/useWindowScrollPosition.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +export function useWindowScrollPosition() { + const [scrollPosition, setScrollPosition] = useState(0); + + const onScroll = useCallback(() => { + setScrollPosition(window.scrollY); + }, []); + + useEffect(() => { + window.addEventListener("scroll", onScroll); + return () => window.removeEventListener("scroll", onScroll); + }, [onScroll]); + + return scrollPosition; +}