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
7 changes: 7 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ const config = {
protocol: "https",
hostname: "*.blob.core.windows.net",
},
{
protocol: "https",
hostname: "raw.githubusercontent.com",
port: "",
pathname: "/toto04/webeep-sync/**",
search: "",
},
],
},
}
Expand Down
167 changes: 167 additions & 0 deletions src/app/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { notFound } from "next/navigation"
import { FiArrowLeft, FiArrowUpRight, FiUploadCloud } from "react-icons/fi"
import { Glass } from "@/components/glass"
import { GradientIcon } from "@/components/gradient-icon"
import { getProjectResources, resourceDetails } from "@/components/projects/resources"
import { Shape, ShapeLayer } from "@/components/shapes"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { getProject } from "@/queries/projects"

export const dynamic = "force-dynamic"

const categoryLabels = {
news: "Novità della community",
general: "Progetto della community",
deprecated: "Progetto non più mantenuto",
} as const

type Props = { params: Promise<{ id: string }> }

async function loadProject(params: Props["params"]) {
const { id } = await params
const projectId = Number(id)
return Number.isInteger(projectId) ? getProject(projectId) : null
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const project = await loadProject(params)
return project ? { title: project.title, description: project.descriptionIt } : { title: "Progetto non trovato" }
}

export default async function ProjectPage({ params }: Props) {
const project = await loadProject(params)
if (!project) notFound()
const resources = getProjectResources(project)

return (
<main className="flex w-full flex-col gap-24 pb-24 md:gap-36 md:pb-36">
<div className="relative">
<ShapeLayer>
<Shape variant="big-teal" className="-top-32 right-0 h-auto w-160 opacity-50" />
</ShapeLayer>
<section className="mx-auto flex w-full max-w-330 flex-col gap-6 px-6 pt-52 md:pt-57.5">
<Link
href="/projects"
className="typo-body-medium flex w-fit items-center gap-2 text-blue-secondary underline-offset-4 hover:underline focus-visible:underline"
>
<FiArrowLeft aria-hidden className="size-4 shrink-0" /> Tutti i progetti
</Link>
<Glass className="grid items-center gap-8 rounded-rectangles border-white/50 bg-linear-to-br from-background-blur to-blue-tertiary/30 p-6 text-center sm:p-12 md:grid-cols-[auto_minmax(0,1fr)] md:gap-12 md:text-start">
{project.logo ? (
// Project logos are app icons with their own background and corners: shown as they are, not framed
<Image
src={project.logo}
alt=""
width={160}
height={160}
sizes="(min-width: 768px) 160px, 112px"
className="mx-auto size-28 object-contain md:size-40"
/>
) : (
<Glass className="mx-auto flex size-28 items-center justify-center rounded-rectangles border-white/50 bg-background-blur p-0 shadow-sm md:size-40">
<GradientIcon icon={FiUploadCloud} className="size-12 md:size-16" />
</Glass>
)}
<div className="flex min-w-0 flex-col gap-4">
{project.category in categoryLabels && (
<p className="typo-label-large text-blue-secondary">{categoryLabels[project.category]}</p>
)}
<h1 className="typo-headline-medium sm:typo-display-medium wrap-anywhere text-balance bg-linear-to-b from-text-primary to-text-secondary bg-clip-text text-transparent">
{project.title}
</h1>
<p className="typo-body-large sm:typo-headline-extrasmall wrap-anywhere max-w-200 text-pretty">
{project.descriptionIt}
</p>
</div>
</Glass>
</section>
</div>

{resources.length > 0 && (
<section
aria-labelledby="risorse-title"
className="mx-auto flex w-full max-w-330 flex-col gap-10 px-6 md:gap-14"
>
<div className="flex flex-col gap-4 text-center md:text-start">
<h2
id="risorse-title"
className="typo-headline-medium sm:typo-display-medium text-balance bg-linear-to-b from-text-primary to-text-secondary bg-clip-text text-transparent"
>
Come usarlo
</h2>
<p className="typo-body-large sm:typo-headline-extrasmall text-pretty">
{resources.length > 1
? "Scegli la versione più adatta a te."
: "Apri il progetto e segui le istruzioni per iniziare."}
</p>
</div>
<ul
className={cn(
"grid gap-6",
resources.length > 1 && "md:grid-cols-2",
resources.length > 2 && "xl:grid-cols-3"
)}
>
{resources.map((resource) => {
const details = resourceDetails[resource.kind]
const external = resource.href.startsWith("http")
return (
<li key={`${resource.kind}-${resource.href}`} className="min-w-0">
<Card
className={cn(
"h-full w-full gap-6 border-white/50 p-6 md:p-8",
resources.length === 1 && "md:flex-row md:items-center md:justify-between md:gap-8"
)}
>
<div className="flex min-w-0 items-start gap-4">
<GradientIcon icon={details.icon} className="size-10 shrink-0" />
<div className="flex min-w-0 flex-col gap-2">
<CardTitle className="typo-headline-small text-balance">{details.title}</CardTitle>
<CardContent className="typo-body-large text-pretty">{details.description}</CardContent>
</div>
</div>
<Button
asChild
size="lg"
className={cn("mt-auto w-full shrink-0 sm:w-fit", resources.length === 1 && "md:mt-0")}
>
<a
href={resource.href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
{details.cta}
<FiArrowUpRight aria-hidden />
</a>
</Button>
</Card>
</li>
)
})}
</ul>
</section>
)}

<section className="mx-auto w-full max-w-330 px-6">
<Glass className="flex flex-col items-center gap-6 rounded-rectangles border-white/50 bg-linear-to-br from-background-blur to-blue-tertiary/30 p-6 text-center sm:p-12">
<h2 className="typo-headline-medium sm:typo-display-medium text-balance bg-linear-to-b from-text-primary to-text-secondary bg-clip-text text-transparent">
Scopri gli altri progetti
</h2>
<p className="typo-body-large sm:typo-headline-extrasmall max-w-200 text-balance">
Strumenti e app realizzati dagli studenti del Politecnico, per gli studenti del Politecnico.
</p>
<Button asChild size="lg" className="w-full sm:w-fit">
<Link href="/projects">
Tutti i progetti <FiArrowUpRight aria-hidden />
</Link>
</Button>
</Glass>
</section>
</main>
)
}
9 changes: 3 additions & 6 deletions src/app/projects/shapes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function CollectionShapes() {
<Shape variant="big-blue" className="top-128 left-[calc(50%-294px)] hidden size-144.5 md:block" />
<Shape
variant="looper"
className="top-[-205.37px] left-[calc(50%-763.83px)] hidden h-[1560.48px] w-[1609.71px] md:block"
className="top-[-205.37px] left-[calc(50%-763.83px)] hidden h-auto w-[1609.71px] md:block"
/>
</ShapeLayer>
)
Expand All @@ -69,10 +69,7 @@ export function UploadShapes() {
{/* Desktop Shapes */}
<Shape variant="big-blue" className="top-24 left-[calc(50%-50px)] hidden size-115 md:block" />
<Shape variant="big-teal" className="top-17.25 left-[calc(50%-801px)] hidden size-243.5 md:block" />
<Shape
variant="looper"
className="-top-15.25 left-[calc(50%-1096.09px)] hidden h-[1560.48px] w-[1609.71px] md:block"
/>
<Shape variant="looper" className="-top-15.25 left-[calc(50%-1096.09px)] hidden h-auto w-[1609.71px] md:block" />
</ShapeLayer>
)
}
Expand All @@ -90,7 +87,7 @@ export function DeprecatedShapes() {
<Shape variant="big-blue" className="top-[364.81px] left-[calc(50%+212.4px)] hidden size-[448.76px] md:block" />
<Shape
variant="looper"
className="top-[-147.41px] left-[calc(50%-268.41px)] hidden h-[1560.62px] w-[1609.84px] rotate-180 md:block"
className="top-[-147.41px] left-[calc(50%-268.41px)] hidden h-auto w-[1609.84px] rotate-180 md:block"
/>
</ShapeLayer>
)
Expand Down
4 changes: 2 additions & 2 deletions src/components/projects/collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function Collection({ projects }: { projects: Project[] }) {
title={project.title}
caption={project.descriptionIt}
icon={FiUploadCloud}
href={project.link ?? undefined}
href={`/projects/${project.id}`}
/>
))}
</div>
Expand Down Expand Up @@ -78,7 +78,7 @@ export function Collection({ projects }: { projects: Project[] }) {
title={project.title}
caption={project.descriptionIt}
icon={FiUploadCloud}
href={project.link ?? undefined}
href={`/projects/${project.id}`}
/>
</div>
</CarouselItem>
Expand Down
4 changes: 2 additions & 2 deletions src/components/projects/community-news.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function CommunityNews({ projects }: { projects: Project[] }) {
caption={project.descriptionIt}
icon={FiCrop}
iconPosition="right"
href={project.link ?? undefined}
href={`/projects/${project.id}`}
/>
))}
</div>
Expand All @@ -51,7 +51,7 @@ export function CommunityNews({ projects }: { projects: Project[] }) {
caption={project.descriptionIt}
icon={FiCrop}
iconPosition="right"
href={project.link ?? undefined}
href={`/projects/${project.id}`}
/>
</div>
</CarouselItem>
Expand Down
4 changes: 2 additions & 2 deletions src/components/projects/deprecated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function Deprecated({ projects }: { projects: Project[] }) {
title={project.title}
caption={project.descriptionIt}
icon={FiUploadCloud}
href={project.link ?? undefined}
href={`/projects/${project.id}`}
/>
))}
</div>
Expand Down Expand Up @@ -70,7 +70,7 @@ export function Deprecated({ projects }: { projects: Project[] }) {
title={project.title}
caption={project.descriptionIt}
icon={FiUploadCloud}
href={project.link ?? undefined}
href={`/projects/${project.id}`}
/>
</div>
</CarouselItem>
Expand Down
49 changes: 49 additions & 0 deletions src/components/projects/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { IconType } from "react-icons"
import { FaAndroid, FaApple, FaGithub, FaLinux, FaWindows } from "react-icons/fa6"
import { FiGlobe } from "react-icons/fi"
import type { ApiOutput } from "@/types"

export type Project = ApiOutput["web"]["projects"]["getAllProjects"][number]

export type ProjectResourceKind = "web" | "windows" | "macos" | "linux" | "android" | "ios" | "source"

/** A way to use or get a project: a download for a platform, the web app or the source code. */
export type ProjectResource = { kind: ProjectResourceKind; href: string }

export const resourceDetails: Record<
ProjectResourceKind,
{ title: string; description: string; cta: string; icon: IconType }
> = {
web: {
title: "Web",
description: "Usalo direttamente dal browser, senza installare nulla.",
cta: "Apri il sito",
icon: FiGlobe,
},
windows: {
title: "Windows",
description: "Scarica l’installer per Windows.",
cta: "Scarica per Windows",
icon: FaWindows,
},
macos: { title: "macOS", description: "Scarica l’app per macOS.", cta: "Scarica per macOS", icon: FaApple },
linux: { title: "Linux", description: "Scarica il pacchetto per Linux.", cta: "Scarica per Linux", icon: FaLinux },
android: { title: "Android", description: "Installa l’app su Android.", cta: "Scarica per Android", icon: FaAndroid },
ios: { title: "iOS", description: "Installa l’app su iPhone e iPad.", cta: "Scarica per iOS", icon: FaApple },
source: {
title: "Codice sorgente",
description: "Consulta il codice, segui il README e contribuisci al progetto.",
cta: "Vai alla repository",
icon: FaGithub,
},
}

/**
* The backend does not expose per-platform downloads yet: until it does, the only resource is the project `link`,
* shown as source code when it points to a repository and as the web app otherwise. Replace this with the API field.
*/
export function getProjectResources(project: Project): ProjectResource[] {
if (!project.link) return []
const isRepository = /^https:\/\/(github\.com|gitlab\.com)\//.test(project.link)
return [{ kind: isRepository ? "source" : "web", href: project.link }]
}
5 changes: 5 additions & 0 deletions src/queries/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ export async function getAllProjects() {
return []
}
}

export async function getProject(id: number) {
const projects = await getAllProjects()
return projects.find((project) => project.id === id) ?? null
}
29 changes: 29 additions & 0 deletions tests/project-images.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import assert from "node:assert/strict"
import { createRequire } from "node:module"
import test from "node:test"

// Load the image configuration without requiring unrelated application credentials.
process.env.SKIP_ENV_VALIDATION = "true"
const { default: nextConfig } = await import("../next.config.js")
const require = createRequire(import.meta.url)
const { default: imageLoader } = require("next/dist/shared/lib/image-loader.js")
const { imageConfigDefault } = require("next/dist/shared/lib/image-config.js")

function loadImage(src) {
return imageLoader({ config: { ...imageConfigDefault, ...nextConfig.images }, src, width: 128, quality: 75 })
}

test("the reported Webeep Sync logo can be rendered by next/image", () => {
const src = "https://raw.githubusercontent.com/toto04/webeep-sync/main/static/icons/icon-256x256.png"
assert.equal(new URL(loadImage(src), "https://polinetwork.org").searchParams.get("url"), src)
})

test("the new image permission is limited to the intended HTTPS repository", () => {
for (const src of [
"https://unconfigured.example/logo.png",
"https://raw.githubusercontent.com/other-owner/other-repo/main/logo.png",
"http://raw.githubusercontent.com/toto04/webeep-sync/main/static/icons/icon-256x256.png",
]) {
assert.throws(() => loadImage(src), /not configured/)
}
})
Loading