diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 6c8380ea..34afdc94 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next" import Link from "next/link" import { FiArrowUpRight } from "react-icons/fi" import { ClosingCta } from "@/components/layout/closing-cta" -import { CONTAINER, PAGE_HERO, PAGE_MAIN, SECTION_TITLE } from "@/components/layout/page" +import { CONTAINER, PAGE_HERO, PAGE_MAIN, SECTION_STACK, SECTION_TITLE } from "@/components/layout/page" import { AssociationBodies } from "@/components/structure/association-bodies" import { BoardGallery } from "@/components/structure/board-gallery" import { LegalStatus } from "@/components/structure/legal-status" @@ -17,7 +17,6 @@ export const metadata: Metadata = { "Un’organizzazione pensata per durare: scopri l’organigramma, il Direttivo, gli organi associativi e i ruoli di PoliNetwork APS.", } -const containerClass = `${CONTAINER} flex flex-col gap-10 md:gap-14` const titleClass = `${SECTION_TITLE} text-center` export default function StructurePage() { @@ -27,7 +26,7 @@ export default function StructurePage() { -
+

Il Direttivo in carica

@@ -38,13 +37,13 @@ export default function StructurePage() {

-
+

Come funziona l’associazione

-
+

Trova il tuo posto in PoliNetwork

diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 256adc98..c1a8d014 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next" import { CollaboraSection } from "@/components/contact/collabora-section" import { EmailSection } from "@/components/contact/email-section" import { GroupsSection } from "@/components/contact/groups-section" +import { CONTAINER, PAGE_HERO, PAGE_MAIN } from "@/components/layout/page" import { Hero } from "@/components/ui/hero" export const metadata: Metadata = { @@ -11,12 +12,14 @@ export const metadata: Metadata = { export default function Contact() { return ( -
- +
+
+ +
diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 8d1c623e..a721908d 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -2,10 +2,11 @@ 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 { FiArrowLeft, FiArrowUpRight } from "react-icons/fi" import { Glass } from "@/components/glass" import { GradientIcon } from "@/components/gradient-icon" -import { SECTION_LEAD, SECTION_TITLE } from "@/components/layout/page" +import { ClosingCta } from "@/components/layout/closing-cta" +import { CONTAINER, PAGE_HERO, PAGE_MAIN, SECTION_LEAD, SECTION_STACK, SECTION_TITLE } from "@/components/layout/page" import { getProjectResources, resourceDetails } from "@/components/projects/resources" import { Button } from "@/components/ui/button" import { Card, CardContent, CardTitle } from "@/components/ui/card" @@ -39,16 +40,21 @@ export default async function ProjectPage({ params }: Props) { const resources = getProjectResources(project) return ( -
-
+
+
Tutti i progetti - - {project.logo ? ( + + {project.logo && ( // Project logos are app icons with their own background and corners: shown as they are, not framed - ) : ( - - - )}
{project.category in categoryLabels && ( @@ -74,10 +76,7 @@ export default async function ProjectPage({ params }: Props) {
{resources.length > 0 && ( -
+

Come usarlo @@ -135,19 +134,17 @@ export default async function ProjectPage({ params }: Props) {

)} -
- -

Scopri gli altri progetti

-

- Strumenti e app realizzati dagli studenti del Politecnico, per gli studenti del Politecnico. -

+ Tutti i progetti -
-
+ } + />
) } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index fbfb0d74..25e85bd6 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,7 +1,9 @@ +import { CONTAINER, PAGE_HERO, PAGE_MAIN } from "@/components/layout/page" import { Collection } from "@/components/projects/collection" import { CommunityNews } from "@/components/projects/community-news" import { Deprecated } from "@/components/projects/deprecated" import { Upload } from "@/components/projects/upload" +import { Hero } from "@/components/ui/hero" import { getAllProjects } from "@/queries/projects" export const metadata = { @@ -11,7 +13,7 @@ export const metadata = { export const dynamic = "force-dynamic" -export default async function Home() { +export default async function ProjectsPage() { const projects = await getAllProjects() const news = projects.filter((project) => project.category === "news") @@ -19,9 +21,12 @@ export default async function Home() { const deprecated = projects.filter((project) => project.category === "deprecated") return ( -
+
+
+ +
- +
diff --git a/src/components/card-caption.tsx b/src/components/card-caption.tsx index 43fe8e3b..70468f5c 100644 --- a/src/components/card-caption.tsx +++ b/src/components/card-caption.tsx @@ -1,3 +1,4 @@ +import Image from "next/image" import Link from "next/link" import type { ReactNode } from "react" import type { IconType } from "react-icons" @@ -9,18 +10,25 @@ export function CardCaption({ caption, icon, iconPosition = "right", + image, href, footer, className, + titleClassName, + captionClassName, }: { title: string caption: ReactNode icon?: IconType iconPosition?: "top" | "right" + /** An image shown in place of the icon, e.g. a project's logo. */ + image?: string href?: string /** Pinned to the bottom of the card, e.g. where a link leads to. */ footer?: ReactNode className?: string + titleClassName?: string + captionClassName?: string }) { return ( // Only cards that lead somewhere react to hover @@ -32,10 +40,19 @@ export function CardCaption({ - {title} - {icon && } + + {/* Gradient per line (box-decoration-clone): a two-line title doesn't fade into a paler second line */} + + {title} + + + {image ? ( + + ) : ( + icon && + )} - + {typeof caption === "string" ?

{caption}

: caption}
{footer &&
{footer}
} diff --git a/src/components/contact-card.tsx b/src/components/contact-card.tsx index 742a8160..716db936 100644 --- a/src/components/contact-card.tsx +++ b/src/components/contact-card.tsx @@ -9,12 +9,12 @@ export type ContactCardData = { export function ContactCard({ title, description, email }: ContactCardData) { return ( - + - {title} + {title} {description} - + {email} diff --git a/src/components/contact/collabora-section.tsx b/src/components/contact/collabora-section.tsx index e035b39e..12dd95c3 100644 --- a/src/components/contact/collabora-section.tsx +++ b/src/components/contact/collabora-section.tsx @@ -1,27 +1,24 @@ import Link from "next/link" import { FiArrowUpRight } from "react-icons/fi" +import { CONTAINER, SECTION_LEAD, SECTION_TITLE } from "@/components/layout/page" import { Button } from "@/components/ui/button" export function CollaboraSection() { return ( -
-
-

- Vuoi sviluppare un progetto insieme a noi? -

+
+
+

Vuoi sviluppare un progetto insieme a noi?

-
-

+

+

Siamo sempre felici di{" "} - collaborare con aziende o associazioni studentesche per portare + collaborare con aziende o associazioni studentesche per portare agli studenti del PoliMi eventi e attività di valore.

-

- Consulta la pagina dedicata per scoprire come possiamo unire le forze! -

+

Consulta la pagina dedicata per scoprire come possiamo unire le forze!

-
-
- Foto di gruppo della community PoliNetwork -
+ Foto di gruppo della community PoliNetwork
) } diff --git a/src/components/home/projects.tsx b/src/components/home/projects.tsx index d0078106..99ec6578 100644 --- a/src/components/home/projects.tsx +++ b/src/components/home/projects.tsx @@ -2,9 +2,9 @@ import Link from "next/link" import { FiArrowUpRight } from "react-icons/fi" import { CardCaption } from "@/components/card-caption" import { CONTAINER } from "@/components/layout/page" +import { PeekCarousel } from "@/components/peek-carousel" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" -import { Carousel, CarouselContent, CarouselDots, CarouselItem } from "../ui/carousel" const featuredCards = [ { @@ -31,7 +31,7 @@ const featuredCards = [ }, ] as const -function ProjectCard({ className, ...card }: (typeof featuredCards)[number] & { className?: string }) { +function FeaturedProjectCard({ className, ...card }: (typeof featuredCards)[number] & { className?: string }) { return ( {featuredCards.map((card) => ( - + ))}
- {/* Mobile: the next card peeks in from the edge, so the row reads as scrollable */} -
- - - {featuredCards.map((card) => ( - - - - ))} - - - -
+ card.title} + render={(card) => } + className="pt-6 sm:hidden" + />
) } diff --git a/src/components/layout/page.ts b/src/components/layout/page.ts index ca8cc332..495b9d2a 100644 --- a/src/components/layout/page.ts +++ b/src/components/layout/page.ts @@ -24,3 +24,6 @@ export const PAGE_MAIN = "flex w-full flex-col gap-24 pb-24 md:gap-32 md:pb-32 x */ export const PAGE_HERO_TOP = "pt-52 md:pt-57.5" export const PAGE_HERO = `${PAGE_HERO_TOP} pb-8 md:pb-12 xl:pb-24` + +/** A section made of a heading block followed by its content. */ +export const SECTION_STACK = `${CONTAINER} flex flex-col gap-10 md:gap-14` diff --git a/src/components/peek-carousel.tsx b/src/components/peek-carousel.tsx new file mode 100644 index 00000000..5d2afda2 --- /dev/null +++ b/src/components/peek-carousel.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react" +import { Carousel, CarouselContent, CarouselDots, CarouselItem } from "@/components/ui/carousel" +import { cn } from "@/lib/utils" + +/** + * Phone carousel whose next card peeks in from the screen edge, so the row reads as scrollable. + * It bleeds over the CONTAINER's 24px phone gutter: use it below `sm` only. + */ +export function PeekCarousel({ + items, + getKey, + render, + className, +}: { + items: readonly T[] + getKey: (item: T) => string | number + render: (item: T) => ReactNode + className?: string +}) { + return ( +
+ + + {items.map((item) => ( + + {render(item)} + + ))} + + + +
+ ) +} diff --git a/src/components/projects/collection.tsx b/src/components/projects/collection.tsx index 61345010..e320f5b2 100644 --- a/src/components/projects/collection.tsx +++ b/src/components/projects/collection.tsx @@ -1,94 +1,75 @@ "use client" -import { useId, useState } from "react" -import { FiArrowDown, FiArrowUp, FiSearch, FiUploadCloud } from "react-icons/fi" -import type { ApiOutput } from "@/types" -import { CardCaption } from "../card-caption" -import { Button } from "../ui/button" -import { Carousel, CarouselContent, CarouselDots, CarouselItem } from "../ui/carousel" +import { useState } from "react" +import { FiSearch } from "react-icons/fi" +import { INLINE_LINK, SECTION_STACK, SECTION_TITLE } from "../layout/page" import { Input } from "../ui/input" +import { ProjectList, ProjectsEmpty } from "./project-list" +import type { Project } from "./resources" -type Project = ApiOutput["web"]["projects"]["getAllProjects"][number] - -const INITIAL_PROJECT_COUNT = 8 +/** Case- and accent-insensitive, so "universita" finds "Università". */ +function normalize(text: string) { + return text + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() +} -export function Collection({ projects }: { projects: Project[] }) { - const [expanded, setExpanded] = useState(false) - const gridId = useId() - const visibleProjects = expanded ? projects : projects.slice(0, INITIAL_PROJECT_COUNT) +/** Lists the collection; a search looks through every project on the page, novelties and deprecated ones included. */ +export function Collection({ projects, searchable }: { projects: Project[]; searchable: Project[] }) { + const [query, setQuery] = useState("") + const needle = normalize(query.trim()) + const words = needle.split(/\s+/) + const matches = needle + ? searchable.filter((project) => { + const text = normalize(`${project.title} ${project.descriptionIt}`) + return words.every((word) => text.includes(word)) + }) + : projects return ( -
-
-

+
+
+

Esplora la raccolta completa dei progetti

-
+ {searchable.length > 0 && ( } - type="text" - placeholder="Search by name" - aria-label="Search by name" - containerClassName="max-w-xl" + type="search" + placeholder="Cerca un progetto" + aria-label="Cerca un progetto per nome o descrizione" + containerClassName="w-full max-w-xl" className="typo-body-medium" + value={query} + onChange={(event) => setQuery(event.target.value)} /> -
+ )}
- {projects.length === 0 ? ( -

Nessun progetto disponibile al momento.

- ) : ( - <> -
-
- {visibleProjects.map((project) => ( - - ))} -
- {projects.length > INITIAL_PROJECT_COUNT && ( -
- -
+

+ {needle && `${matches.length} progetti trovati`} +

+ + {needle ? ( + <>Nessun progetto corrisponde a “{query.trim()}”. + ) : ( + <> + La raccolta è vuota per ora.{" "} + + Proponi il primo progetto + + )} -
- -
- - - {projects.map((project) => ( - -
- -
-
- ))} -
- -
-
- - )} + + } + />
) } diff --git a/src/components/projects/community-news.tsx b/src/components/projects/community-news.tsx index ee7440ed..98a8fb75 100644 --- a/src/components/projects/community-news.tsx +++ b/src/components/projects/community-news.tsx @@ -1,68 +1,27 @@ -import { FiCrop } from "react-icons/fi" -import type { ApiOutput } from "@/types" -import { CardCaption } from "../card-caption" -import { Carousel, CarouselContent, CarouselDots, CarouselItem } from "../ui/carousel" -import { Hero } from "../ui/hero" - -type Project = ApiOutput["web"]["projects"]["getAllProjects"][number] +import { INLINE_LINK, SECTION_LEAD, SECTION_STACK, SECTION_TITLE } from "../layout/page" +import { ProjectList, ProjectsEmpty } from "./project-list" +import type { Project } from "./resources" export function CommunityNews({ projects }: { projects: Project[] }) { return ( -
- - -
-
-

- Le novità della community -

-

- Rimani aggiornato sulle idee appena condivise dagli studenti del Politecnico -

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

Nessuna novità disponibile al momento.

- ) : ( - <> - {/* Desktop Grid */} -
- {projects.map((project) => ( - - ))} -
- - {/* Mobile Carousel */} -
- - - {projects.map((project) => ( - -
- -
-
- ))} -
- -
-
- - )} +
+
+

+ Le novità della community +

+

Rimani aggiornato sulle idee appena condivise dagli studenti del Politecnico.

+ + Nessuna novità al momento.{" "} + + Hai un'idea? Proponila + + + } + />
) } diff --git a/src/components/projects/deprecated.tsx b/src/components/projects/deprecated.tsx index 34a0fd08..96b23070 100644 --- a/src/components/projects/deprecated.tsx +++ b/src/components/projects/deprecated.tsx @@ -1,87 +1,24 @@ -"use client" - -import { useId, useState } from "react" -import { FiArrowDown, FiArrowUp, FiUploadCloud } from "react-icons/fi" -import type { ApiOutput } from "@/types" -import { CardCaption } from "../card-caption" -import { Button } from "../ui/button" -import { Carousel, CarouselContent, CarouselDots, CarouselItem } from "../ui/carousel" - -type Project = ApiOutput["web"]["projects"]["getAllProjects"][number] - -const INITIAL_PROJECT_COUNT = 8 +import { SECTION_LEAD, SECTION_STACK, SECTION_TITLE } from "../layout/page" +import { ProjectList, ProjectsEmpty } from "./project-list" +import type { Project } from "./resources" export function Deprecated({ projects }: { projects: Project[] }) { - const [expanded, setExpanded] = useState(false) - const gridId = useId() - const visibleProjects = expanded ? projects : projects.slice(0, INITIAL_PROJECT_COUNT) - return ( -
-
-
-

Progetti deprecati

-

- Qui raccogliamo i progetti non più aggiornati o attivi.
- Puoi contribuire a riportarli in vita, migliorarli o usarli come base per nuove idee. -

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

Nessun progetto deprecato al momento.

- ) : ( - <> -
-
- {visibleProjects.map((project) => ( - - ))} -
- {projects.length > INITIAL_PROJECT_COUNT && ( -
- -
- )} -
- -
- - - {projects.map((project) => ( - -
- -
-
- ))} -
- -
-
- - )} +
+
+

+ Progetti deprecati +

+

+ Qui raccogliamo i progetti non più aggiornati o attivi. Puoi contribuire a riportarli in vita, migliorarli o + usarli come base per nuove idee. +

+ Nessun progetto deprecato al momento.} + />
) } diff --git a/src/components/projects/project-card.tsx b/src/components/projects/project-card.tsx new file mode 100644 index 00000000..291485d4 --- /dev/null +++ b/src/components/projects/project-card.tsx @@ -0,0 +1,32 @@ +import { FiArrowRight } from "react-icons/fi" +import { CardCaption } from "../card-caption" +import { getProjectResources, type Project, resourceDetails } from "./resources" + +/** A project in the lists: opens the project page, and says what it offers there when the project has a link. */ +export function ProjectCard({ project }: { project: Project }) { + const [resource] = getProjectResources(project) + const details = resource && resourceDetails[resource.kind] + + return ( + + {details && ( + + + {details.title} + + )} + + + } + /> + ) +} diff --git a/src/components/projects/project-list.tsx b/src/components/projects/project-list.tsx new file mode 100644 index 00000000..72ee5c4a --- /dev/null +++ b/src/components/projects/project-list.tsx @@ -0,0 +1,74 @@ +"use client" + +import { type ReactNode, useId, useState } from "react" +import { FiArrowDown, FiArrowUp } from "react-icons/fi" +import { PeekCarousel } from "../peek-carousel" +import { Button } from "../ui/button" +import { ProjectCard } from "./project-card" +import type { Project } from "./resources" + +/** + * Cards keep a readable width at every viewport (16rem min, one fr max), so the column count follows the space + * instead of jumping at fixed breakpoints. On phones the list becomes a carousel with the next card peeking in. + */ +export function ProjectList({ + projects, + initialCount, + empty, +}: { + projects: Project[] + /** Show only this many cards on larger screens until expanded. */ + initialCount?: number + empty: ReactNode +}) { + const [expanded, setExpanded] = useState(false) + const gridId = useId() + + if (projects.length === 0) return empty + + const collapsible = initialCount !== undefined && projects.length > initialCount + const visible = collapsible && !expanded ? projects.slice(0, initialCount) : projects + + return ( + <> +
+
    + {visible.map((project) => ( +
  • + +
  • + ))} +
+ {collapsible && ( + + )} +
+ + project.id} + render={(project) => } + className="sm:hidden" + /> + + ) +} + +/** Shown in place of an empty list: says so plainly and points to the one thing the reader can do about it. */ +export function ProjectsEmpty({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/projects/resources.ts b/src/components/projects/resources.ts index 09227e03..15759167 100644 --- a/src/components/projects/resources.ts +++ b/src/components/projects/resources.ts @@ -1,11 +1,11 @@ import type { IconType } from "react-icons" -import { FaAndroid, FaApple, FaGithub, FaLinux, FaWindows } from "react-icons/fa6" +import { FaAndroid, FaApple, FaGithub, FaLinux, FaTelegram, 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" +export type ProjectResourceKind = "web" | "telegram" | "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 } @@ -20,6 +20,12 @@ export const resourceDetails: Record< cta: "Apri il sito", icon: FiGlobe, }, + telegram: { + title: "Telegram", + description: "Lo trovi su Telegram, senza installare nient’altro.", + cta: "Apri su Telegram", + icon: FaTelegram, + }, windows: { title: "Windows", description: "Scarica l’installer per Windows.", @@ -40,10 +46,14 @@ export const resourceDetails: Record< /** * 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. + * classified by its host: a repository, a Telegram bot or channel, or else the web app. 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 }] + const kind = /^https:\/\/(github\.com|gitlab\.com)\//.test(project.link) + ? "source" + : /^https:\/\/t\.me\//.test(project.link) + ? "telegram" + : "web" + return [{ kind, href: project.link }] } diff --git a/src/components/projects/upload.tsx b/src/components/projects/upload.tsx index 5bcd90bf..b057c9e2 100644 --- a/src/components/projects/upload.tsx +++ b/src/components/projects/upload.tsx @@ -1,22 +1,24 @@ import { FiMail } from "react-icons/fi" import { CardCaption } from "../card-caption" +import { CONTAINER, SECTION_LEAD, SECTION_TITLE } from "../layout/page" const cards = [ { title: "Contatti", caption: ( -
- - info@polinetwork.org - -
+ + info@polinetwork.org + ), }, { title: "Requisiti", caption: ( -
    -
  • opensource
  • +
      +
    • open source
    • utile alla community
    • senza scopo di lucro
    @@ -26,32 +28,32 @@ const cards = [ export function Upload() { return ( -
    -
    -
    -

    Hai un'idea?

    -

    Proponila qui!

    -
    -
    -

    +

    +
    +
    +
    +

    + Hai un'idea? +

    +

    Proponila qui!

    +
    +

    Qui puoi proporre nuovi progetti o suggerire migliorie al sito. Il team di PoliNetwork può aiutarti a sviluppare la tua idea e, se rispetta i requisiti, pubblicarla qui.

    Dopo l'invio riceverai una risposta dai nostri admin con le istruzioni per proseguire.

    -
    -
    -

    +

    I diritti dei progetti rimangono tutti dei rispettivi progettisti, PoliNetwork non si assume responsabilità.

    -
    -
    - {cards.map((card) => ( - - ))} +
    + {cards.map((card) => ( + + ))} +
    )