diff --git a/public/logos/esn.svg b/public/logos/esn.svg deleted file mode 100644 index 60ad33c7..00000000 --- a/public/logos/esn.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - 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/associations/page.tsx b/src/app/associations/page.tsx index e4760a91..8a8d2426 100644 --- a/src/app/associations/page.tsx +++ b/src/app/associations/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import { AssociationsList } from "@/components/associations/associations-list" +import { CONTAINER, PAGE_HERO, PAGE_MAIN } from "@/components/layout/page" import { Hero } from "@/components/ui/hero" import { getAllAssociations } from "@/queries/associations" @@ -14,12 +15,17 @@ export default async function AssociationsPage() { const associations = await getAllAssociations() return ( -
- +
+
+ +
-
- -
+ {/* Figma's list column: 1200px at 1728 */} +
+
+ +
+
) } 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/groups/page.tsx b/src/app/groups/page.tsx index 1b6a4e18..b395c918 100644 --- a/src/app/groups/page.tsx +++ b/src/app/groups/page.tsx @@ -30,9 +30,9 @@ export default function Home() {
-

+

Groups -

+
@@ -46,6 +46,7 @@ export default function Home() { icon={group.icon} cta={group.cta} align="start" + hoverEffect className="w-full max-w-108" size="compact" /> diff --git a/src/app/matricole/page.tsx b/src/app/matricole/page.tsx index 1ac27122..59248e53 100644 --- a/src/app/matricole/page.tsx +++ b/src/app/matricole/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next" +import { CONTAINER, PAGE_MAIN } from "@/components/layout/page" import { FAQsPage } from "@/components/matricole/faqs" import { MatricoleGuides } from "@/components/matricole/guides" import { MatricoleIntro } from "@/components/matricole/intro" @@ -14,7 +15,7 @@ export const dynamic = "force-dynamic" export default async function MatricolePage() { const content = await getFreshmanContent() return ( -
+
{content ? ( <> @@ -22,7 +23,7 @@ export default async function MatricolePage() { ) : ( -

+

Le risorse per le matricole non sono disponibili al momento. Riprova più tardi.

)} 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/accordion-association/accordion-content.tsx b/src/components/accordion-association/accordion-content.tsx index 69f03cc7..1437aedb 100644 --- a/src/components/accordion-association/accordion-content.tsx +++ b/src/components/accordion-association/accordion-content.tsx @@ -10,19 +10,11 @@ export function AccordionContent({ return ( -
- {children} -
+ {/* From md the text lines up with the name: trigger padding (32) + logo (64) + gap (24) */} +
{children}
) } diff --git a/src/components/accordion-association/accordion-item.tsx b/src/components/accordion-association/accordion-item.tsx index 5668b289..0abe9a37 100644 --- a/src/components/accordion-association/accordion-item.tsx +++ b/src/components/accordion-association/accordion-item.tsx @@ -6,14 +6,16 @@ import { AccordionBackground } from "./accordion-background" export function AccordionItem({ className, children, ...props }: React.ComponentProps) { return ( - + - {} +
{children}
diff --git a/src/components/accordion-association/accordion-trigger.tsx b/src/components/accordion-association/accordion-trigger.tsx index 221a3f27..715c06c2 100644 --- a/src/components/accordion-association/accordion-trigger.tsx +++ b/src/components/accordion-association/accordion-trigger.tsx @@ -13,10 +13,9 @@ export function AccordionTrigger({ svg]:rotate-180", className )} diff --git a/src/components/accordion-association/accordion.tsx b/src/components/accordion-association/accordion.tsx index ac919542..888a7d3e 100644 --- a/src/components/accordion-association/accordion.tsx +++ b/src/components/accordion-association/accordion.tsx @@ -3,5 +3,11 @@ import type * as React from "react" import { cn } from "@/lib/utils" export function Accordion({ className, ...props }: React.ComponentProps) { - return + return ( + + ) } diff --git a/src/components/accordion-association/index.tsx b/src/components/accordion-association/index.tsx index 470f3cb6..ff257a04 100644 --- a/src/components/accordion-association/index.tsx +++ b/src/components/accordion-association/index.tsx @@ -4,7 +4,35 @@ import { Accordion } from "./accordion" import { AccordionContent } from "./accordion-content" import { AccordionItem } from "./accordion-item" import { AccordionTrigger } from "./accordion-trigger" -import type { AccordionAssociationProps } from "./types" +import type { AccordionAssociationItem, AccordionAssociationProps } from "./types" + +const LOGO_CLASS = "size-10 shrink-0 rounded-full bg-white sm:size-12 md:size-16" + +/** First letter of the first two words: "Lista Aperta - Per il diritto…" → "LA" */ +function initials(name: string) { + return name + .split(/\s+/) + .filter((word) => /\p{L}/u.test(word)) + .slice(0, 2) + .map((word) => word.match(/\p{L}/u)?.[0] ?? "") + .join("") + .toUpperCase() +} + +function AssociationLogo({ name, logo }: Pick) { + // The name sits right next to the logo, so both are decorative for screen readers + if (!logo) { + return ( + + {initials(name)} + + ) + } + return +} export default function AccordionAssociation({ accordionItems, defaultValue }: AccordionAssociationProps) { return ( @@ -12,35 +40,39 @@ export default function AccordionAssociation({ accordionItems, defaultValue }: A {accordionItems.map((item) => ( -
- {item.name} - {item.name} -
+ + + {/* Long single words may still break on the narrowest phones rather than run under the chevron */} + {item.name} +
- {item.content} -
- - Segui l'associazione - -
- {item.links.map((link) => ( - - - - - ))} + {item.content &&

{item.content}

} + {item.links.length > 0 && ( +
+ Segui l'associazione +
    + {item.links.map((link) => ( +
  • + + + + +
  • + ))} +
-
+ )} ))} diff --git a/src/components/accordion-association/types.ts b/src/components/accordion-association/types.ts index fce94d35..216727e5 100644 --- a/src/components/accordion-association/types.ts +++ b/src/components/accordion-association/types.ts @@ -4,7 +4,8 @@ import type { GradientIconType } from "../gradient-icon" export type AccordionAssociationItem = { value: string name: string - logo: StaticImageData | string + /** `null` when the association has no logo: its initials are shown instead */ + logo: StaticImageData | string | null content: React.ReactNode links: { key: string diff --git a/src/components/associations/associations-list.tsx b/src/components/associations/associations-list.tsx index 847a39a3..65dd312a 100644 --- a/src/components/associations/associations-list.tsx +++ b/src/components/associations/associations-list.tsx @@ -13,7 +13,6 @@ import { import AccordionAssociation from "@/components/accordion-association" import type { GradientIconType } from "@/components/gradient-icon" import type { ApiOutput } from "@/types" -import esnLogo from "../../../public/logos/esn.svg" type Association = ApiOutput["web"]["associations"]["getAllAssociations"][number] type AssociationLinks = Association["links"] @@ -33,13 +32,19 @@ const LINK_CONFIG: { key: keyof AssociationLinks; label: string; icon: GradientI export function AssociationsList({ associations }: { associations: Association[] }) { if (associations.length === 0) { - return

Nessuna associazione disponibile al momento.

+ // The query returns an empty list on backend errors too, so the message covers both cases + return ( +

+ Le associazioni non sono disponibili al momento. Riprova più tardi. +

+ ) } const accordionItems = associations.map((association) => ({ value: String(association.id), name: association.name, - logo: association.logo ?? esnLogo, + // No placeholder logo: an association without one gets its initials (see AccordionAssociation) + logo: association.logo, content: association.descriptionIt, links: LINK_CONFIG.filter(({ key }) => association.links[key]).map(({ key, label, icon, mailto }) => { const value = association.links[key] as string @@ -47,9 +52,5 @@ export function AssociationsList({ associations }: { associations: Association[] }), })) - return ( -
- -
- ) + 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/card-course-group.tsx b/src/components/card-course-group.tsx index bfd21a3a..558a8de3 100644 --- a/src/components/card-course-group.tsx +++ b/src/components/card-course-group.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils" import { Card, CardAction, CardTitle } from "./ui/card" export const cardCourseGroupVariants = cva( - "flex h-fit w-full min-w-0 flex-1 flex-row items-center gap-3 px-5 py-4 font-normal leading-6 tracking-[0.03125rem] sm:gap-5 sm:px-7.5 sm:py-6.25", + "@container h-auto w-full min-w-0 px-4 py-3 font-normal leading-6 tracking-[0.03125rem] sm:px-6 sm:py-4", { variants: { secondary: { @@ -33,7 +33,6 @@ export function CardCourseGroup({ tgGroupId, iconTelegram: IconTelegram = LiaTelegramPlane, secondary = false, - stacked = false, }: { groupName: string waLink?: string @@ -42,38 +41,42 @@ export function CardCourseGroup({ tgLink?: string tgGroupId?: number iconTelegram?: IconType - stacked?: boolean } & VariantProps) { - const actionClassName = cn("rounded-full p-2 sm:p-3.75", secondary ? "bg-[#51A2FF]" : "bg-[#74D4FF]") + // The platform buttons are the only links in the row, so they carry the hover state, not the card + const actionClassName = cn( + "rounded-full p-2 transition-[filter] group-hover/action:brightness-90 sm:p-3", + secondary ? "bg-[#51A2FF]" : "bg-[#74D4FF]" + ) return ( - - - {groupName} - -
- - + + {/* In a card narrower than 20rem the name gets its own line, so it doesn't wrap word by word */} +
+ + {groupName} + +
+ + +
) @@ -100,7 +103,13 @@ function PlatformAction({ return ( <> - + {groupId !== undefined && ( @@ -108,8 +117,12 @@ function PlatformAction({ selectedReport={{ groupId, type: platform, reportType: "broken_link", reportedLink: link }} selectedGroupName={groupName} trigger={ - } /> diff --git a/src/components/card-course.tsx b/src/components/card-course.tsx index 25fa7766..6e562432 100644 --- a/src/components/card-course.tsx +++ b/src/components/card-course.tsx @@ -18,7 +18,8 @@ export function CardCourse({ iconSelect?: IconType }) { return ( - + // Always rendered inside a link or button: the row itself is the hover target +
{courseName} {(location || language) && ( @@ -37,7 +38,10 @@ export function CardCourse({ )}
- +
) diff --git a/src/components/card-group/index.tsx b/src/components/card-group/index.tsx index 84c994ae..8abe2f1c 100644 --- a/src/components/card-group/index.tsx +++ b/src/components/card-group/index.tsx @@ -5,16 +5,18 @@ import type { CardGroupProps } from "./types" export function CardGroup({ icon, title, description, children, horizontal = false, className }: CardGroupProps) { return ( - + {horizontal ? ( -
+
{children}
) : ( <> -
{children}
+
{children}
)} diff --git a/src/components/card-icon/index.tsx b/src/components/card-icon/index.tsx index 79105d70..0a7fc50d 100644 --- a/src/components/card-icon/index.tsx +++ b/src/components/card-icon/index.tsx @@ -9,7 +9,7 @@ import type { CardIconProps } from "./types" import { getAlignmentClasses, getCardPaddingClasses, getContentGapClasses, getTitleSizeClasses } from "./utils" export function CardIcon(props: CardIconProps) { - const { title, icon, size = "md", href, hoverEffect = false, align = "center", className } = props + const { title, icon, size = "md", href, hoverEffect = Boolean(props.href), align = "center", className } = props const description = "description" in props ? props.description : undefined const cta = "cta" in props ? props.cta : undefined const Root = href ? "a" : "div" diff --git a/src/components/card-icon/types.ts b/src/components/card-icon/types.ts index d40cbef8..cdff14ae 100644 --- a/src/components/card-icon/types.ts +++ b/src/components/card-icon/types.ts @@ -22,6 +22,7 @@ export type SharedCardProps = { size?: ResponsiveCardSize align?: CardAlign href?: string + /** Defaults to whether the card has an `href`: only cards that lead somewhere react to hover. */ hoverEffect?: boolean className?: string } 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/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 ( -
-
- - - -
-

{courseName}

-

- Laurea {currentLevel.name} -

-
- - - -
- - {(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/materials.tsx b/src/components/home/materials.tsx index fff6659b..9c732929 100644 --- a/src/components/home/materials.tsx +++ b/src/components/home/materials.tsx @@ -79,7 +79,6 @@ export function Materials() { description={card.description} icon={card.icon} href={card.href} - hoverEffect={Boolean(card.href)} size="md" className="hidden h-full xl:block" /> 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/matricole/cards.ts b/src/components/matricole/cards.ts index 20889aea..dbd1e6b7 100644 --- a/src/components/matricole/cards.ts +++ b/src/components/matricole/cards.ts @@ -43,3 +43,13 @@ export function freshmanCardProps(card: FreshmanCard): CardIconProps { href: card.href || undefined, } } + +/** + * The large resource cards (featured, "Guida della matricola", "Materiali"). Their one-sentence descriptions were + * capped at 240px inside cards three times as wide; 448px keeps them to one or two lines. + */ +export const FEATURED_CARD = { + align: "start", + size: "compact", + className: "h-full min-h-50 [&_p]:max-w-md", +} as const satisfies Partial diff --git a/src/components/matricole/faqs.tsx b/src/components/matricole/faqs.tsx index 3b6b4329..7a7d27fd 100644 --- a/src/components/matricole/faqs.tsx +++ b/src/components/matricole/faqs.tsx @@ -1,4 +1,6 @@ import AccordionList from "@/components/accordion-list" +import { CONTAINER, SECTION_TITLE } from "@/components/layout/page" +import { cn } from "@/lib/utils" import type { FreshmanContent } from "@/queries/types" export function FAQsPage({ faqs }: { faqs: FreshmanContent["faqs"] }) { @@ -8,12 +10,10 @@ export function FAQsPage({ faqs }: { faqs: FreshmanContent["faqs"] }) { content: faq.descriptionIt, })) return ( -
-

- Domande Frequenti tra le Matricole -

+
+

Domande Frequenti tra le Matricole

-
+
diff --git a/src/components/matricole/guides.tsx b/src/components/matricole/guides.tsx index 73ad7508..77b047dd 100644 --- a/src/components/matricole/guides.tsx +++ b/src/components/matricole/guides.tsx @@ -1,7 +1,9 @@ import { CardIcon } from "@/components/card-icon" +import { CONTAINER, SECTION_TITLE } from "@/components/layout/page" +import { cn } from "@/lib/utils" import type { FreshmanContent } from "@/queries/types" import { CardGroup } from "../card-group" -import { freshmanCardProps } from "./cards" +import { FEATURED_CARD, freshmanCardProps } from "./cards" import { hub, techTools } from "./constants" export function MatricoleGuides({ content }: { content: FreshmanContent }) { @@ -9,55 +11,54 @@ export function MatricoleGuides({ content }: { content: FreshmanContent }) { const materials = content.resources.filter((card) => card.group === "materials") const tools = content.resources.filter((card) => card.group === "tools") return ( -
-
-

Guide e Utility

+
+

Guide e Utility

-
- {/* Hidden in mobile */} +
+ {/* 5/12 for the large card: at 1:1 the 2×2 grid next to it squeezed its titles onto two lines */} +
+ {/* Hidden on phones, as in the mobile design */} {guides.slice(0, 1).map((guide) => ( ))} -
+
{guides.slice(1).map((guide) => ( - + ))}
- -
+ +
{content.telegram.map((card) => ( ))}
-
- {/* hidden in mobile */} +
+ {/* Hidden on phones, as in the mobile design */} {materials.map((material) => ( ))} - -
+ +
{tools.map((card) => ( ))} diff --git a/src/components/matricole/intro.tsx b/src/components/matricole/intro.tsx index 6152cdbb..f1d4ccb7 100644 --- a/src/components/matricole/intro.tsx +++ b/src/components/matricole/intro.tsx @@ -1,33 +1,26 @@ import { CardIcon } from "@/components/card-icon" +import { CONTAINER, PAGE_HERO_TOP } from "@/components/layout/page" +import { Hero } from "@/components/ui/hero" +import { cn } from "@/lib/utils" import type { FreshmanContent } from "@/queries/types" -import { freshmanCardProps } from "./cards" +import { FEATURED_CARD, freshmanCardProps } from "./cards" export function MatricoleIntro({ resources }: { resources: FreshmanContent["resources"] }) { return ( -
-
-
-

- Matricole -

-

- Ecco una raccolta curata di risorse utili, guide e strumenti per supportare il tuo percorso. -

-
+ // The featured resources close the hero, so it takes only the hero's top padding +
+ -
- {resources - .filter((resource) => resource.group === "featured") - .map((resource) => ( - - ))} -
+ {/* Hidden on phones, as in the mobile design */} +
+ {resources + .filter((resource) => resource.group === "featured") + .map((resource) => ( + + ))}
) 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) => ( + + ))} +
    ) diff --git a/src/components/storia/hero.tsx b/src/components/storia/hero.tsx index 071ac805..172cd87e 100644 --- a/src/components/storia/hero.tsx +++ b/src/components/storia/hero.tsx @@ -11,7 +11,7 @@ export function HistoryHero() { title="La nostra storia" description="Da un gruppo Telegram a una rete costruita dagli studenti, per gli studenti." gradientDescription - className="[&_h2]:typo-display-fluid-extralarge [&_p]:max-w-145 [&_p]:text-balance" + descriptionClassName="max-w-145 text-balance" />