-
- Hai una richiesta specifica?
-
-
+
+ Hai una richiesta specifica?
+
Per tutto il resto,{" "}
-
+
puoi scriverci direttamente
.
@@ -57,7 +56,8 @@ export function EmailSection() {
Qui sotto trovi i contatti in base al tipo di richiesta.
-
+ {/* 2×2 between one and four columns, so no card is left alone on a row */}
+
{contactCards.map((card) => (
))}
diff --git a/src/components/contact/groups-section.tsx b/src/components/contact/groups-section.tsx
index 37440fcd..f8cf963a 100644
--- a/src/components/contact/groups-section.tsx
+++ b/src/components/contact/groups-section.tsx
@@ -1,40 +1,44 @@
import Image from "next/image"
import Link from "next/link"
import { FiArrowUpRight } from "react-icons/fi"
+import { CONTAINER, INLINE_LINK, SECTION_LEAD, SECTION_TITLE } from "@/components/layout/page"
import { Button } from "@/components/ui/button"
export function GroupsSection() {
return (
-
-
-
+
+
+
Hai mille dubbi sul Poli?
Siamo pronti ad aiutarti!
-
+
Il modo più rapido per ottenere risposte su esami, burocrazia universitaria o sulla vita al Politecnico è{" "}
-
+
entrare nel gruppo del tuo corso
, su Telegram o su WhatsApp, dove gli admin sono presenti ogni giorno.
-
-
-
-
+
)
}
diff --git a/src/components/groups/extra-groups.tsx b/src/components/groups/extra-groups.tsx
index 6140f2f2..f0b25c96 100644
--- a/src/components/groups/extra-groups.tsx
+++ b/src/components/groups/extra-groups.tsx
@@ -1,6 +1,5 @@
-import Link from "next/link"
-import { FiArrowLeft } from "react-icons/fi"
import { CardCourseGroup } from "@/components/card-course-group"
+import { GROUPS_COLUMN, GroupsPageHeader } from "@/components/groups/page-header"
import { getVisibleGroups } from "@/queries/groups"
import { EXTRA_LABEL, matchesLabelBranch } from "@/utils/labels"
import { mergeGroupsByTitle } from "@/utils/merge-groups"
@@ -12,22 +11,11 @@ export async function ExtraGroups() {
)
return (
-
-
-
-
-
-
-
Gruppi Extra
-
-
+
+
{extraGroups.length > 0 ? (
-
+
{extraGroups.map((g) => (
) : (
-
- Nessun gruppo extra disponibile al momento.
-
+ Nessun gruppo extra disponibile al momento.
)}
)
diff --git a/src/components/groups/groups-result.tsx b/src/components/groups/groups-result.tsx
index c1b3ba6d..b0ff9c99 100644
--- a/src/components/groups/groups-result.tsx
+++ b/src/components/groups/groups-result.tsx
@@ -1,7 +1,6 @@
-import Link from "next/link"
import { notFound } from "next/navigation"
-import { FiArrowLeft, FiX } from "react-icons/fi"
import { CardCourseGroup } from "@/components/card-course-group"
+import { GROUPS_COLUMN, GroupsPageHeader } from "@/components/groups/page-header"
import { getLevel, getSchool } from "@/components/wizard/constants"
import { getVisibleGroups } from "@/queries/groups"
import type { VisibleGroup } from "@/queries/types"
@@ -21,7 +20,7 @@ import { createStepHref } from "@/utils/step-href"
const stepHref = createStepHref("/groups/didattica")
-function GroupCard({ group, secondary, stacked }: { group: MergedGroup; secondary?: boolean; stacked?: boolean }) {
+function GroupCard({ group, secondary }: { group: MergedGroup; secondary?: boolean }) {
return (
)
}
@@ -41,19 +39,19 @@ function GroupSection({ title, groups }: { title: string; groups: VisibleGroup[]
if (merged.length === 0) return null
return (
-
-
-
{title}
-
+
+
+
{title}
+
{merged.length} Gruppi
-
+
{merged.map((g) => (
))}
-
+
)
}
@@ -101,50 +99,33 @@ export async function GroupsResult({
mergedLevelGroups.length > 0 ||
courseGroups.length > 0
+ const generalGroups = [
+ ...mergedSiteGroups.map((group) => ({ group, secondary: false })),
+ ...mergedSchoolGroups.map((group) => ({ group, secondary: false })),
+ ...mergedLevelGroups.map((group) => ({ group, secondary: true })),
+ ...mergedGeneralCourseGroups.map((group) => ({ group, secondary: true })),
+ ]
+
return (
-
-
-
- {(mergedSiteGroups.length > 0 ||
- mergedSchoolGroups.length > 0 ||
- mergedLevelGroups.length > 0 ||
- mergedGeneralCourseGroups.length > 0) && (
-
- {mergedSiteGroups.map((g) => (
-
- ))}
- {mergedSchoolGroups.map((g) => (
-
- ))}
- {mergedLevelGroups.map((g) => (
-
- ))}
- {mergedGeneralCourseGroups.map((g) => (
-
+
+
+
+ {/* The site, school, level and course groups */}
+ {generalGroups.length > 0 && (
+
+
+ Gruppi generali
+
+ {generalGroups.map(({ group, secondary }) => (
+
))}
-
+
)}
{cohorts.map((cohort) => (
@@ -156,7 +137,7 @@ export async function GroupsResult({
))}
{!hasAnyGroup && (
-
+
Nessun gruppo disponibile al momento per questo corso.
)}
diff --git a/src/components/groups/page-header.tsx b/src/components/groups/page-header.tsx
new file mode 100644
index 00000000..ef06eb8c
--- /dev/null
+++ b/src/components/groups/page-header.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link"
+import { FiArrowLeft, FiX } from "react-icons/fi"
+
+/** Figma's list column for group results (~1040px at 1728), with the shared 24/40px gutters. */
+export const GROUPS_COLUMN = "mx-auto w-full min-w-0 max-w-5xl px-6 sm:px-10"
+
+const iconLinkClass =
+ "grid size-10 shrink-0 place-items-center rounded-full bg-white/60 outline-none transition-colors hover:bg-white focus-visible:ring-2 focus-visible:ring-blue-secondary"
+
+/**
+ * Back button, centred title and optional close button. A three-column grid keeps a long course name from running
+ * under the buttons on phones.
+ */
+export function GroupsPageHeader({
+ title,
+ subtitle,
+ backHref,
+ replaceBack = false,
+ closeHref,
+}: {
+ title: string
+ subtitle?: string
+ backHref: string
+ /** Go back without leaving a history entry, as the wizard steps do. */
+ replaceBack?: boolean
+ closeHref?: string
+}) {
+ return (
+
+
+
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {closeHref && (
+
+
+
+ )}
+
+ )
+}
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 && (
-
- setExpanded((previous) => !previous)}
- >
- {expanded ? "Mostra di meno" : "Mostra di più"}
- {expanded ? : }
-
-
+
+ {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 && (
-
- setExpanded((previous) => !previous)}
- >
- {expanded ? "Mostra di meno" : "Mostra di più"}
- {expanded ? : }
-
-
- )}
-
-
-
-
-
- {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 && (
+
setExpanded((previous) => !previous)}
+ >
+ {expanded ? "Mostra di meno" : `Mostra tutti (${projects.length})`}
+ {expanded ? : }
+
+ )}
+
+
+ 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) => (
+
+ ))}
+
)
diff --git a/src/components/wizard/course-step.tsx b/src/components/wizard/course-step.tsx
index 5f187649..a3b570ac 100644
--- a/src/components/wizard/course-step.tsx
+++ b/src/components/wizard/course-step.tsx
@@ -84,7 +84,11 @@ export async function CourseStep({
const courseCampus = campusFacet(facets)
const courseLanguage = languageFacet(facets)
return (
-
+
diff --git a/src/components/wizard/wizard-shell.tsx b/src/components/wizard/wizard-shell.tsx
index f4adad57..f3f6bd40 100644
--- a/src/components/wizard/wizard-shell.tsx
+++ b/src/components/wizard/wizard-shell.tsx
@@ -34,8 +34,8 @@ export function WizardShell({
return (
-
-
+
+
{
- const groups = await trpc.groups.search.getAll.query()
- return groups.filter((g): g is VisibleGroup => !g.hide && !!g.link)
+ try {
+ const groups = await trpc.groups.search.getAll.query()
+ return groups.filter((g): g is VisibleGroup => !g.hide && !!g.link)
+ } catch (error) {
+ console.error(error)
+ return []
+ }
}
export async function getCoursesForLevel(school: string, level: string) {