Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client"

import { useState } from "react"

import Emoji from "@/components/Emoji"
import { Image } from "@/components/Image"
import { Button } from "@/components/ui/buttons/Button"

import { cn } from "@/lib/utils/cn"

const AvatarWithFallback = ({
username,
avatarUrl,
}: {
username: string
avatarUrl: string
}) => {
const [imageError, setImageError] = useState(false)

// Generate consistent avatar colors using design system colors
const avatarColors = [
"bg-primary",
"bg-accent-a",
"bg-accent-b",
"bg-accent-c",
"bg-blue-600",
"bg-purple-600",
"bg-pink-600",
"bg-teal-600",
"bg-blue-500",
"bg-purple-500",
"bg-pink-500",
"bg-teal-500",
]

// Simple hash function for consistent color selection
const hash = username.split("").reduce((a, b) => {
a = (a << 5) - a + b.charCodeAt(0)
return a & a
}, 0)

const avatarColorClass = avatarColors[Math.abs(hash) % avatarColors.length]
const initials = username.slice(0, 1).toUpperCase()

if (imageError || !avatarUrl) {
return (
<div
className={cn(
"me-4 hidden h-[30px] w-[30px] items-center justify-center rounded-full text-sm font-semibold text-white sm:flex sm:h-10 sm:w-10",
avatarColorClass
)}
>
{initials}
</div>
)
}

return (
<div className="relative me-4 hidden h-[30px] w-[30px] sm:block sm:h-10 sm:w-10">
<Image
fill
className="rounded-full object-cover"
src={avatarUrl}
alt={username}
onError={() => setImageError(true)}
/>
</div>
)
}

export const Leaderboard = ({ translators }) => {
const [filterAmount, updateFilterAmount] = useState(10)

const showMore = () => {
if (filterAmount < translators.length) {
updateFilterAmount(filterAmount + 50)
}
}

return (
<div className="mb-8 w-full bg-background-highlight shadow-md">
<div className="bg-muted text-foreground mb-[1px] flex w-full items-center justify-between p-4">
<div className="flex">
<div className="w-10 opacity-40">#</div>
<div className="div-row me-8 flex items-center break-words">
<p>Translator</p>
</div>
</div>
<div className="div-row flex min-w-[20%] items-start">
<p>Total words</p>
</div>
</div>
{translators.slice(0, filterAmount).map((translator, index) => {
const { username, avatarUrl, totalCosts } = translator

const transformedAvatarUrl = avatarUrl
? avatarUrl.replace(
"https://crowdin-static.downloads.crowdin.com",
"https://crowdin-static.cf-downloads.crowdin.com"
)
: avatarUrl

let emoji: string | null = null
if (index === 0) {
emoji = ":trophy:"
} else if (index === 1) {
emoji = ":2nd_place_medal:"
} else if (index === 2) {
emoji = ":3rd_place_medal:"
}
return (
<div
key={index}
className="text-foreground hover:rounded-base hover:bg-accent/50 mb-[1px] flex w-full items-center justify-between px-4 py-2 shadow-sm hover:shadow-md"
>
<div className="flex">
<div className="flex w-10 items-center">
{emoji ? (
<Emoji className="me-4 text-[2rem]" text={emoji} />
) : (
<span className="opacity-40">{index + 1}</span>
)}
</div>
<div className="me-8 flex flex-row items-center break-words">
<AvatarWithFallback
username={username}
avatarUrl={transformedAvatarUrl}
/>
<div className="max-w-[100px] sm:max-w-none">{username}</div>
</div>
</div>
<div className="div-row flex min-w-[20%] items-start">
<Emoji text=":writing:" className="me-2 text-2xl sm:block" />
<p>{totalCosts}</p>
</div>
</div>
)
})}
{translators.length > filterAmount && (
<div className="flex w-full flex-col justify-center px-8 py-0 lg:flex-row">
<Button
variant="ghost"
onClick={showMore}
className="m-2 mx-0 flex h-full w-full items-center justify-center rounded-full px-6 py-4 lg:mx-2 lg:w-auto"
>
<span className="text-center text-md font-semibold leading-none md:text-lg md:font-normal">
Show more
</span>
</Button>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { setRequestLocale } from "next-intl/server"

import { List as ButtonDropdownList } from "@/components/ButtonDropdown"
import ContentHero, { ContentHeroProps } from "@/components/Hero/ContentHero"
import LeftNavBar from "@/components/LeftNavBar"
import MainArticle from "@/components/MainArticle"
import { ApplyNow } from "@/components/Translatathon/ApplyNow"
import PaperformCallToAction from "@/components/Translatathon/PaperformCallToAction"

import { dataLoader } from "@/lib/utils/data/dataLoader"
import { getMetadata } from "@/lib/utils/metadata"

import { BASE_TIME_UNIT } from "@/lib/constants"

import { Leaderboard } from "./_components/Leaderboard"

import { fetchTranslatathonTranslators } from "@/lib/api/fetchTranslatathonTranslators"

// 24 hours
const REVALIDATE_TIME = BASE_TIME_UNIT * 24

const loadData = dataLoader(
[["translatathonTranslators", fetchTranslatathonTranslators]],
REVALIDATE_TIME * 1000
)

const Page = async ({ params }: { params: Promise<{ locale: string }> }) => {
const { locale } = await params

setRequestLocale(locale)

const [translatathonTranslators] = await loadData()

const heroProps = {
title: "2025 Ethereum.org Translatathon",
breadcrumbs: {
slug: "/contributing/translation-program/translatathon/leaderboard",
startDepth: 1,
},
heroImg: "/images/heroes/translatathon-hero.svg",
blurDataURL: "",
description: (
<>
<p>Leaderboard for the 2025 Ethereum.org Translatathon</p>
</>
),
buttons: [
<PaperformCallToAction
key="apply"
content="Apply to translate"
variant="solid"
/>,
],
} satisfies ContentHeroProps

const dropdownLinks: ButtonDropdownList = {
text: "Translatathon menu",
ariaLabel: "Translatathon menu",
items: [
{
text: "Translatathon",
href: "/contributing/translation-program/translatathon",
matomo: {
eventCategory: "translatathon menu",
eventAction: "click",
eventName: "translatathon translatathon hub",
},
},
{
text: "Leaderboard",
href: "/contributing/translation-program/translatathon/leaderboard",
matomo: {
eventCategory: "translatathon menu",
eventAction: "click",
eventName: "translatathon leaderboard",
},
},
{
text: "Details and submission criteria",
href: "/contributing/translation-program/translatathon/details",
matomo: {
eventCategory: "translatathon menu",
eventAction: "click",
eventName: "translatathon details and submission criteria",
},
},
{
text: "Terms and conditions",
href: "/contributing/translation-program/translatathon/terms-and-conditions",
matomo: {
eventCategory: "translatathon menu",
eventAction: "click",
eventName: "translatathon terms and conditions",
},
},
],
}

return (
<>
<ContentHero {...heroProps} />

<div className="mx-auto mb-16 flex w-full flex-col justify-between lg:flex-row lg:pt-16 lg:first-of-type:[&_h2]:mt-0">
<LeftNavBar
className="max-lg:hidden"
dropdownLinks={dropdownLinks}
tocItems={[
{
title: "Leaderboard",
url: "#leaderboard",
},
{
title: "Apply now",
url: "#apply-now",
},
]}
maxDepth={0}
/>
<MainArticle className="relative flex-[1_1_992px] px-8 pb-8">
<div className="flex flex-col gap-4">
<h2 id="leaderboard">Leaderboard</h2>
<p>
The leaderboard shows all translations submitted by Translatathon
participants across all eligible projects during the translation
period. It is updated once daily and may not reflect the real-time
scores.
</p>
<p>
None of the scores below are final and do not include any bonus
points, potential disqualifications, or other adjustments.
</p>
<p>
Final scores will be announced after all the evaluations are
completed!
</p>
{translatathonTranslators.length > 0 ? (
<Leaderboard translators={translatathonTranslators} />
) : (
<div className="text-center text-body-medium">
No data available
</div>
)}
</div>
<div id="apply-now">
<ApplyNow />
</div>
</MainArticle>
</div>
</>
)
}

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params

return await getMetadata({
locale,
slug: ["translatathon"],
title: "2025 Ethereum.org Translatathon",
description: "2025 Ethereum.org Translatathon",
})
}

export default Page
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Here is a list of all the eligible projects that are part of the 2025 Translatat

- [Remix LearnEth](https://crowdin.com/project/remix-learneth)

- [web3.py](https://crowdin.com/project/web3py)

## Evaluation process {#evaluation-process}

All translations will be subject to QA and feedback, where professional linguists will evaluate submissions based on quality and accuracy.
Expand Down
13 changes: 8 additions & 5 deletions src/components/Translatathon/ApplyNow.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
import Callout from "@/components/Callout"
import CalloutSSR from "@/components/CalloutSSR"

import { Button } from "../ui/buttons/Button"
import { Flex } from "../ui/flex"

import { APPLICATION_END_DATE } from "./constants"
import PaperformModal from "./PaperformModal"

import { useTranslation } from "@/hooks/useTranslation"
import DolphinImage from "@/public/images/translatathon/translatathon_dolphin.png"

// TODO: Confirm deadline for applying

export const ApplyNow = () => {
const { t } = useTranslation("page-translatathon")

const dateToday = new Date()
const deadline = new Date(APPLICATION_END_DATE)

if (dateToday < deadline) {
return (
<div className="pt-12">
<Callout
<CalloutSSR
image={DolphinImage}
titleKey="page-translatathon:translatathon-apply-now"
descriptionKey="page-translatathon:translatathon-apply-now-desc"
title={t("translatathon-apply-now")}
description={t("translatathon-apply-now-desc")}
className="flex-1 basis-[416px] items-center text-center"
>
<Flex className="m-auto">
Expand All @@ -29,7 +32,7 @@ export const ApplyNow = () => {
title="Apply to Translate"
/>
</Flex>
</Callout>
</CalloutSSR>
</div>
)
} else {
Expand Down
Loading