Import repositories from gitlab

This commit is contained in:
Julien Aldon
2026-01-19 11:43:59 +01:00
commit 68b7405c52
131 changed files with 17192 additions and 0 deletions

15
next/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
/out/
/build
.DS_Store
yarn-debug.log*
yarn-error.log*
.vercel
*.tsbuildinfo
next-env.d.ts

5
next/.env.example Normal file
View File

@@ -0,0 +1,5 @@
NEXT_PUBLIC_CONTENT_URI=http://127.0.0.1:1337/api
NEXT_PUBLIC_IMG_URI=http://127.0.0.1:1337
NEXT_PUBLIC_ORIGIN=http://localhost:3000
NEXT_PRIVATE_CONTENT_URI=https://fefan-backend:1337/api
NEXT_PRIVATE_IMG_URI=https://fefan-backend:1337

3
next/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

38
next/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env

32
next/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:20.10-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
# Copy source for runtime build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build at container startup instead of build time
RUN mkdir .next && chown nextjs:nodejs .next
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Build and start in one step
CMD ["sh", "-c", "yarn build && yarn start"]

36
next/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

19
next/api/index.js Normal file
View File

@@ -0,0 +1,19 @@
import qs from "qs";
async function getData(path, query) {
const queryString = qs.stringify(query);
const isServerSide = typeof window === "undefined";
const res = await fetch(
`${
isServerSide
? process.env.NEXT_PRIVATE_CONTENT_URI ??
"http://fefan-backend:1337/api"
: process.env.NEXT_PUBLIC_CONTENT_URI ?? "https://content.fefan.fr/api"
}/${path}?${queryString}`,
{ cache: "no-store" }
);
return await res.json();
}
export default getData;

24
next/app/contact/page.js Normal file
View File

@@ -0,0 +1,24 @@
import getData from "@/api";
import { BlocksRenderer } from "@strapi/blocks-react-renderer";
import styles from "./style.module.scss";
import Email from "@/components/email";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
return {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: "Contactez nous !",
};
}
export default async function Contact() {
const site = await getData("site", {});
const content = site.data?.attributes.contact_text;
return (
<main className={styles.main}>
<article>{content ? <BlocksRenderer content={content} /> : null}</article>
{site.data?.attributes.contact_mail ? <Email></Email> : null}
</main>
);
}

View File

@@ -0,0 +1,37 @@
.main {
display: flex;
flex: 1 1 0%;
flex-flow: column;
align-items: center;
article {
width: 60rem;
display: flex;
flex-flow: column;
max-width: 90vw;
align-self: center;
h2, h3, h4 {
font-family: var(--font-details);
}
h2 {
align-self: center;
font-family: var(--font-details);
font-size: 2rem;
font-weight: 400;
}
p {
margin: 10px;
}
}
}
@media only screen and (max-width: 641px) {
.main {
article {
text-align: justify;
}
}
}

View File

@@ -0,0 +1,216 @@
import getData from "@/api";
import styles from "./style.module.scss";
import EditionElement from "@/components/editionElement";
import EditionGallery from "@/components/editionGallery";
import PressBlock from "@/components/pressBlock";
import Empty from "@/components/empty";
import VideoBlock from "@/components/videoBlock";
import Fanfare from "@/components/fanfare";
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }) {
const data = await getData(`editions/${params.editionId}`, {
populate: {
flyer: {
fields: ["name", "alternativeText", "caption", "url"],
},
},
filters: {
$or: [{ id: { $eq: params.editionId } }],
},
});
const activeEditionData = await getData("site", {
populate: {
edition: {
fields: ["id"],
},
},
fields: ["author"],
});
const activeEdition = activeEditionData?.data?.attributes.edition.data.id;
const edition = data.data?.attributes.publishedAt ? data : null;
const flyer = edition?.data?.attributes.flyer.data.attributes;
return edition
? {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: edition.title,
description: edition.description,
alternates: {
canonical:
data.data.id !== activeEdition
? `${process.env.NEXT_PUBLIC_ORIGIN}/editions/${params.editionId}`
: `${process.env.NEXT_PUBLIC_ORIGIN}`,
},
openGraph: {
title: edition.title,
url:
data.data.id !== activeEdition
? `${process.env.NEXT_PUBLIC_ORIGIN}/editions/${params.editionId}`
: `${process.env.NEXT_PUBLIC_ORIGIN}`,
description: edition.description,
images: {
url: `${process.env.NEXT_PUBLIC_IMG_URI}${flyer.url}`,
width: flyer.width,
height: flyer.height,
},
authors: [activeEditionData.data.attributes.author],
type: "website",
locale: "fr_FR",
siteName: "Le Fefan - Festival de Fanfares",
},
}
: {};
}
export default async function Edition({ params }) {
const data = await getData(`editions/${params.editionId}`, {
populate: {
flyer: {
fields: ["name", "alternativeText", "caption", "url"],
},
gallery: {
fields: ["name", "alternativeText", "caption", "url"],
},
programs: {
fields: ["map_uri", "introduction", "description", "title", "type"],
populate: {
bands: {
fields: ["name", "location"],
},
},
},
statistics: {
fields: ["name", "value", "publishedAt"],
},
social_links: {
fields: ["uri", "type"],
},
articles: {
fields: ["title", "link", "excerpt", "publishedAt"],
},
fields: ["movie"],
},
filters: {
$or: [{ id: { $eq: params.editionId } }],
},
});
const edition = data.data?.attributes.publishedAt ? data : null;
const flyer = edition?.data?.attributes.flyer.data.attributes;
const gallery = edition?.data?.attributes.gallery.data;
const statistics = edition?.data?.attributes.statistics.data;
const allArticles = edition?.data?.attributes.articles.data;
const articles = allArticles
? allArticles.filter((el) => el.attributes.publishedAt != null)
: [];
const movie = edition.data.attributes.movie
? edition.data.attributes.movie
: null;
const programs = edition?.data?.attributes?.programs?.data
? edition.data.attributes.programs.data
: [];
const program =
programs.filter((el) => el.attributes.type === "city-wide")[0] ?? null;
const bands = program ? program?.attributes?.bands.data : [];
return (
<main className={styles.main}>
{edition ? (
<>
<h2>{edition.data.attributes.title}</h2>
<h3>{edition.data.attributes.subtitle}</h3>
<EditionElement
flyerImg={`${process.env.NEXT_PUBLIC_IMG_URI}${flyer.url}`}
flyerAlt={flyer.alternativeText}
blocks={statistics.map(({ id, attributes }) => ({
id,
type: "stat",
title: attributes.name,
value: attributes.value,
}))}
/>
{movie && bands ? (
<section className={styles.smallProgram}>
{bands.length > 0 ? (
<article className={styles.featuring}>
<h4>Les fanfares</h4>
{bands.map(({ id, attributes: attr }) => {
return (
<Fanfare
key={id}
location={attr.location}
name={attr.name}
/>
);
})}
</article>
) : null}
{movie ? (
<>
<VideoBlock
src={movie}
title={`Aftermovie du festival ${
edition.data.attributes?.title ?? ""
}`}
/>
</>
) : null}
</section>
) : null}
{articles.length > 0 ? (
<>
<h4>Ils ont parlé de nous !</h4>
{articles.map(({ id, attributes }, index) => {
const offset = index + (articles.length - gallery.length) / 2;
return index < gallery.length ? (
<PressBlock
key={id}
left={{
type: "article",
title: attributes.title,
content: attributes.excerpt,
link: attributes.link,
}}
right={{
type: "image",
alt: gallery[index].attributes.alternativeText,
src: `${process.env.NEXT_PUBLIC_IMG_URI}${gallery[index].attributes.url}`,
}}
/>
) : (
<PressBlock
key={id}
left={{
type: "article",
title: attributes.title,
content: attributes.excerpt,
link: attributes.link,
}}
right={
articles[offset]
? {
type: "article",
title: articles[offset].title,
content: articles[offset].excerpt,
link: articles[offset].link,
}
: { type: null }
}
/>
);
})}
</>
) : null}
{articles.length <= gallery.length ? (
<>
<h4>Les photos du Fefan</h4>
<EditionGallery images={gallery.slice(articles.length)} />
</>
) : null}
</>
) : (
<Empty message="Il n'y a aucune information pour cette édition à afficher." />
)}
</main>
);
}

View File

@@ -0,0 +1,64 @@
.main {
flex: 1 1 0%;
display: flex;
align-items: center;
flex-flow: column;
padding-top: 1rem;
h2 {
font-family: var(--font-details);
font-size: 2rem;
font-weight: 400;
margin: 0;
}
h3 {
font-size: 0.75rem;
color: var(--fg-1);
font-weight: 700;
text-transform: uppercase;
margin: 0;
margin-bottom: 1rem;
}
h4 {
font-family: var(--font-details);
font-size: 1.8rem;
font-weight: 400;
}
.smallProgram {
margin-top: 2rem;
display: flex;
.featuring {
h4 {
grid-column: 1 / -1;
}
display: grid;
align-self: center;
grid-template-columns: repeat(2, calc(80rem / 6));
align-self: stretch;
padding: 0 0.5rem;
justify-self: stretch;
align-items: stretch;
justify-items: stretch;
align-content: start;
grid-auto-rows: max-content;
}
}
}
@media (max-width: 60rem) {
.main {
.smallProgram {
flex-direction: column;
.featuring {
grid-template-columns: 1fr;
align-self: center;
}
}
}
}

132
next/app/editions/page.js Normal file
View File

@@ -0,0 +1,132 @@
import getData from "@/api";
import styles from "./style.module.scss";
import EditionElement from "@/components/editionElement";
import Empty from "@/components/empty";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
const site = await getData("site", {
fields: ["description", "author"],
});
const data = await getData("editions", {
populate: {
gallery: {
fields: ["name", "url", "alternativeText"],
},
statistics: {
fields: ["name", "value"],
},
flyer: {
fields: ["name", "url", "alternativeText", "width", "height"],
},
},
});
const editions = data.data;
return site.data
? {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: "Editions précédentes — Le Fefan",
description: site.data.attributes.description,
alternates: {
canonical: "/editions",
},
openGraph: {
title: "Editions précédentes — Le Fefan",
url: `${process.env.NEXT_PUBLIC_ORIGIN}/prog/city-wide`,
description: site.data.attributes.description,
images: editions.map(({ attributes: attr }) => ({
width: attr.flyer.data.attributes.width,
height: attr.flyer.data.attributes.height,
alt: attr.flyer.data.attributes.alternativeText,
url: attr.flyer.data.attributes.url,
})),
authors: [site.data.attributes.author],
type: "website",
locale: "fr_FR",
siteName: "Le Fefan - Festival de Fanfares",
},
}
: {};
}
export default async function Editions() {
const site = await getData("site", {
fields: ["id"],
populate: {
edition: {
fields: ["id"],
},
},
});
const data = await getData("editions", {
populate: {
gallery: {
fields: ["name", "url", "alternativeText"],
},
statistics: {
fields: ["name", "value"],
},
flyer: {
fields: ["name", "url", "alternativeText"],
},
fields: ["movie"],
},
});
const editions = (data.data ?? []).filter(
(e) => e.id !== site.data?.attributes.edition.data?.id
);
return (
<main className={styles.main}>
{editions ? (
editions.map(({ id, attributes: attr }) => {
const stats = attr.statistics.data.map(({ id, attributes }) => ({
id,
type: "stat",
title: attributes.name,
value: attributes.value,
}));
const images = attr.gallery.data.map(({ id, attributes }) => ({
id,
type: "image",
title: attributes.alternativeText,
value: `${process.env.NEXT_PUBLIC_IMG_URI}${attributes.url}`,
}));
const movie = attr.movie
? [
{
id: "movie",
type: "video",
title: `Aftermovie ${attr.title}`,
value: attr.movie,
mode: "thumbnail",
},
]
: null;
return (
<EditionElement
full
key={id}
href={`/editions/${id}`}
title={attr.title}
blocks={
movie
? stats.concat(movie).concat(images)
: stats.concat(images)
}
flyerImg={`${process.env.NEXT_PUBLIC_IMG_URI}${attr.flyer.data.attributes.url}`}
flyerAlt={attr.flyer.data.attributes.alternativeText}
/>
);
})
) : (
<Empty message="Il n'y a aucune édition à afficher."></Empty>
)}
</main>
);
}

View File

@@ -0,0 +1,12 @@
.main {
flex: 1 1 0%;
display: flex;
flex-flow: column;
align-items: center;
height: 100vh;
overflow: auto;
scroll-snap-type: y mandatory;
// .editionLink {
// }
}

BIN
next/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

44
next/app/globals.css Normal file
View File

@@ -0,0 +1,44 @@
:root {
--bg-0: #FFFFCC;
--fg-0: #412D0A;
--fg-0-translucid: rgba(65, 45, 10, 0.35);
--fg-1: #957948;
--fg-2: #CCC08F;
--fg-3: #DDDDAA;
--fefan-1: #1B519E;
--fefan-2: #295DAB;
--fefan-3: #32A1DA;
--fefan-4: #B8529F;
--transition-time: 0.2s;
--font-body: 'Krub', sans-serif;
--font-details: 'Shadows Into Light', sans-serif;
--border-width: 1px;
}
html, body {
background: var(--bg-0);
color: var(--fg-0);
height: 100vh;
margin: 0;
font-family: var(--font-body);
display: flex;
flex-direction: column;
}
a:not(.not-a-link) {
text-decoration-color: transparent;
color: transparent;
background: linear-gradient(175deg, var(--fefan-1) 0%, var(--fefan-1) 45%, var(--fefan-2) 55%, var(--fefan-3) 70%, var(--fefan-4) 85%);
background-clip: text;
background-size: 250% 100%;
transition: all ease var(--transition-time);
&:hover {
text-decoration-color: var(--fefan-1);
background-position-x: 100%;
}
&:visited {
color: unset;
}
}

28
next/app/layout.js Normal file
View File

@@ -0,0 +1,28 @@
import './globals.css'
import Header from '@/components/header'
import Footer from '@/components/footer'
export const dynqmic = 'force-dynamic'
export const metadata = {
generator: 'Next.js',
applicationName: 'Le Fefan',
keywords: ['Fefan', 'Festival', 'Fanfare', 'Cuivres', 'Villeurbanne', 'Evenement', 'Musique'],
authors: [{ name: 'Girasol' }],
creator: 'Girasol',
}
export default function RootLayout({ children }) {
return (
<html lang="fr">
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Krub:wght@500;700&family=Shadows+Into+Light&display=swap" />
</head>
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}

22
next/app/legal/page.js Normal file
View File

@@ -0,0 +1,22 @@
import getData from "@/api";
import { BlocksRenderer } from "@strapi/blocks-react-renderer";
import styles from "./style.module.scss";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
return {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: "Mentions légales",
};
}
export default async function Legal() {
const site = await getData("site", {});
const content = site.data?.attributes.legal;
return (
<main className={styles.main}>
<article>{content ? <BlocksRenderer content={content} /> : null}</article>
</main>
);
}

View File

@@ -0,0 +1,29 @@
.main {
display: flex;
flex: 1 1 0%;
flex-flow: column;
align-items: center;
article {
width: 60rem;
display: flex;
flex-flow: column;
max-width: 90vw;
align-self: center;
h2, h3, h4 {
font-family: var(--font-details);
}
h2 {
align-self: center;
font-family: var(--font-details);
font-size: 2rem;
font-weight: 400;
}
p {
margin: 0px;
}
}
}

114
next/app/page.js Normal file
View File

@@ -0,0 +1,114 @@
import getData from "@/api";
import styles from "./page.module.scss";
import { BlocksRenderer } from "@strapi/blocks-react-renderer";
import Button from "@/components/button";
import ImageBlock from "@/components/imageBlock";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
const site = await getData("site", {
populate: {
edition: {
populate: {
flyer: {
fields: [
"name",
"url",
"alternativeText",
"caption",
"width",
"height",
],
},
programs: true,
},
fields: ["id", "introduction", "title", "subtitle", "description"],
},
},
fields: ["author"],
});
const current_edition = site.data?.attributes.edition.data?.attributes;
const flyer =
site.data?.attributes.edition.data?.attributes.flyer.data.attributes;
return current_edition
? {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: current_edition.title,
description: current_edition.description,
alternates: {
canonical: "/",
},
openGraph: {
title: current_edition.title,
url: `${process.env.NEXT_PUBLIC_ORIGIN}`,
description: current_edition.description,
images: {
url: `${process.env.NEXT_PUBLIC_IMG_URI}${flyer.url}`,
width: flyer.width,
height: flyer.height,
},
authors: [site.data.attributes.author],
type: "website",
locale: "fr_FR",
siteName: "Le Fefan - Festival de Fanfares",
},
}
: {};
}
export default async function Home() {
const site = await getData("site", {
populate: {
edition: {
populate: {
flyer: {
fields: ["name", "url", "alternativeText", "caption"],
},
programs: true,
},
fields: ["id", "introduction", "title", "subtitle"],
},
},
});
const current_edition = site.data?.attributes.edition.data?.attributes;
const flyer =
site.data?.attributes.edition.data?.attributes.flyer.data.attributes;
const introduction = current_edition?.introduction;
const programs = current_edition?.programs.data;
return (
<main className={styles.main}>
{current_edition ? (
<section className={styles.home}>
{flyer ? (
<ImageBlock
className={styles.flyer}
alt={flyer.alternativeText}
src={`${process.env.NEXT_PUBLIC_IMG_URI}${flyer.url}`}
/>
) : null}
<article>
<h2>{current_edition.title}</h2>
<h3>{current_edition.subtitle}</h3>
{introduction ? <BlocksRenderer content={introduction} /> : null}
<nav>
{programs.filter((e) => e.attributes.type === "city-wide")
.length > 0 ? (
<Button as="a" type="primary" href="/prog/city-wide">
Voir la programmation
</Button>
) : null}
{programs.filter((e) => e.attributes.type === "village").length >
0 ? (
<Button as="a" type="secondary" href="/prog/village">
Le Village
</Button>
) : null}
</nav>
</article>
</section>
) : null}
</main>
);
}

84
next/app/page.module.scss Normal file
View File

@@ -0,0 +1,84 @@
.main {
flex: 1;
display: flex;
flex-flow: column;
.home {
margin-top: 2rem;
display: flex;
width: 60rem;
max-width: 90vw;
align-items: flex-start;
align-self: center;
article {
padding-left: 1rem;
}
.flyer {
width: 20rem;
max-width: 30vw;
}
nav {
margin-bottom: 1rem;
}
p {
}
h2 {
font-family: var(--font-details);
margin: -0.5rem 0;
font-size: 2rem;
font-weight: 400;
}
h3 {
font-size: 0.75rem;
color: var(--fg-1);
font-weight: 700;
text-transform: uppercase;
margin: 0;
}
}
}
@media only screen and (max-width: 992px) {
.main {
.home {
flex-flow: column;
align-items: center;
article {
padding-left: 0px;
}
.flyer {
width: 50vw;
max-width: 50vw;
}
}
}
}
@media only screen and (max-width: 641px) {
.main {
.home {
article {
text-align: justify;
}
.flyer {
width: 90vw;
max-width: 90vw;
padding-bottom: 2rem;
padding-bottom: 2rem;
}
nav {
display: flex;
align-items: center;
flex-flow: column;
}
}
}
}

View File

@@ -0,0 +1,136 @@
import getData from "@/api";
import styles from "./style.module.scss";
import Fanfare from "@/components/fanfare";
import TimeSlot from "@/components/timeSlot";
import Empty from "@/components/empty";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
const site = await getData("site", {
populate: {
edition: {
populate: {
flyer: {
fields: [
"name",
"url",
"alternativeText",
"caption",
"width",
"height",
],
},
programs: true,
},
fields: ["id", "introduction", "title", "subtitle"],
},
},
fields: ["author"],
});
const current_edition = site.data?.attributes.edition.data?.attributes;
const flyer =
site.data?.attributes.edition.data?.attributes.flyer.data.attributes;
const programs = current_edition?.programs.data;
const cityWide = programs?.filter(
(e) => e.attributes.type === "city-wide"
)[0];
return cityWide
? {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: cityWide.attributes.title,
description: cityWide.attributes.description,
alternates: {
canonical: "/prog/city-wide",
},
openGraph: {
title: cityWide.attributes.title,
url: `${process.env.NEXT_PUBLIC_ORIGIN}/prog/city-wide`,
description: cityWide.attributes.description,
images: {
url: `${process.env.NEXT_PUBLIC_IMG_URI}${flyer.url}`,
width: flyer.width,
height: flyer.height,
},
authors: [site.data.attributes.author],
type: "website",
locale: "fr_FR",
siteName: "Le Fefan - Festival de Fanfares",
},
}
: {};
}
export default async function CityWide() {
const site = await getData("site", {
populate: {
edition: {
populate: {
programs: {
populate: {
bands: {
fields: ["*"],
},
time_slots: {
fields: ["*"],
},
},
},
},
},
},
});
const edition = site.data?.attributes.edition ?? {};
const program =
edition.data?.attributes.programs.data.filter(
({ attributes: r }) => r.type === "city-wide"
)[0] ?? {};
const time_slots = program.attributes?.time_slots.data ?? [];
return (
<main className={styles.main}>
{program.attributes ? (
<>
<h2>{program.attributes.title}</h2>
<section className={styles.program}>
{time_slots.map(({ id, attributes: attr }) => {
return (
<TimeSlot
key={id}
content={attr.content}
start={attr.start}
end={attr.end}
/>
);
})}
</section>
<section className={styles.program}>
<article className={styles.featuring}>
{program.attributes.bands.data.length === 0 ? null : (
<h3>Les Fanfares</h3>
)}
<p className={styles.introduction}>
{program.attributes.description}
</p>
{program.attributes.bands.data.map(({ id, attributes: attr }) => {
return (
<Fanfare key={id} location={attr.location} name={attr.name} />
);
})}
</article>
<iframe
className={styles.map}
src={program.attributes.map_uri}
width={640}
height={480}
></iframe>
</section>
<section className={styles.program}></section>
</>
) : (
<Empty message="Pas de programmation à afficher pour le moment"></Empty>
)}
</main>
);
}

View File

@@ -0,0 +1,95 @@
.main {
display: flex;
flex: 1 1 0%;
flex-flow: column;
.program {
display: grid;
width: 80rem;
max-width: 90vw;
align-self: center;
grid-template-columns: repeat(3, calc(80rem / 3));
grid-auto-rows: max-content;
&+.program {
margin-top: 2rem;
}
h3 {
grid-column: 1 / -1;
font-family: var(--font-details);
margin: 0.25rem 0px;
font-size: 2rem;
font-weight: 400;
justify-self: stretch;
}
}
.map {
width: auto;
grid-column: span 2;
place-self: stretch;
border: medium;
}
.featuring {
display: grid;
grid-template-columns: repeat(2, calc(80rem / 6));
place-self: stretch;
padding: 0px 0.5rem;
place-items: stretch;
align-content: start;
grid-auto-rows: max-content;
h3 {
font-family: var(--font-details);
grid-column: 1 / -1;
margin: 0.25rem 0px;
font-size: 2rem;
font-weight: 400;
justify-self: stretch;
max-width: 80vw;
}
.introduction {
grid-column: 1 / -1;
justify-self: stretch;
text-align: left;
max-width: 90vw;
padding-right: 2rem;
}
p {
margin-top: 0px;
}
}
h2 {
align-self: center;
font-family: var(--font-details);
font-size: 2rem;
font-weight: 400;
}
}
@media only screen and (max-width: 80rem) {
.main {
.program {
grid-template-columns: repeat(2, calc(50vw - 3rem));
}
}
}
@media only screen and (max-width: 60rem) {
.main {
.program {
grid-template-columns: repeat(1, 90vw);
}
.featuring {
.introduction {
padding-right: unset;
}
}
}
}

View File

@@ -0,0 +1,131 @@
import Empty from "@/components/empty";
import styles from "./style.module.scss";
import getData from "@/api";
import ImageBlock from "@/components/imageBlock";
import { BlocksRenderer } from "@strapi/blocks-react-renderer";
import TimeSlot from "@/components/timeSlot";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
const site = await getData("site", {
populate: {
edition: {
populate: {
flyer: {
fields: [
"name",
"url",
"alternativeText",
"caption",
"width",
"height",
],
},
programs: true,
},
fields: ["id", "introduction", "title", "subtitle"],
},
},
fields: ["author"],
});
const current_edition = site.data?.attributes.edition.data?.attributes;
const flyer =
site.data?.attributes.edition.data?.attributes.flyer.data.attributes;
const programs = current_edition?.programs.data;
const village = programs?.filter((e) => e.attributes.type === "village")[0];
return village
? {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: village.attributes.title,
description: village.attributes.description,
alternates: {
canonical: "/prog/village",
},
openGraph: {
title: village.attributes.title,
url: `${process.env.NEXT_PUBLIC_ORIGIN}/prog/village`,
description: village.attributes.description,
images: {
url: `${process.env.NEXT_PUBLIC_IMG_URI}${flyer.url}`,
width: flyer.width,
height: flyer.height,
},
authors: [site.data.attributes.author],
type: "website",
locale: "fr_FR",
siteName: "Le Fefan - Festival de Fanfares",
},
}
: {};
}
export default async function Village() {
const site = await getData("site", {
populate: {
edition: {
populate: {
programs: {
populate: {
sticker: {
fields: ["alternativeText", "url"],
},
bands: {
fields: ["*"],
},
time_slots: {
fields: ["*"],
},
},
},
},
},
},
});
const edition = site.data?.attributes.edition ?? {};
const program =
edition.data?.attributes.programs.data.filter(
({ attributes: r }) => r.type === "village"
)[0] ?? {};
const time_slots = program.attributes?.time_slots.data ?? [];
return (
<main className={styles.main}>
{program.attributes ? (
<>
<h2>{program.attributes?.title ?? "Le Village"}</h2>
<section className={styles.villageSection}>
<article>
{program.attributes?.introduction ? (
<BlocksRenderer content={program.attributes.introduction} />
) : null}
</article>
{program.attributes?.sticker?.data ? (
<ImageBlock
src={`${process.env.NEXT_PUBLIC_IMG_URI}${program.attributes.sticker.data.attributes.url}`}
alt={program.attributes.sticker.data.attributes.alternativeText}
className={styles.villageImg}
/>
) : (
<div></div>
)}
<article>
{time_slots.map(({ id, attributes: attr }) => {
return (
<TimeSlot
key={id}
content={attr.content}
start={attr.start}
end={attr.end}
/>
);
})}
</article>
</section>
</>
) : (
<Empty message="Le village est encore secret"></Empty>
)}
</main>
);
}

View File

@@ -0,0 +1,40 @@
.main {
flex: 1;
display: flex;
flex-flow: column;
h2 {
font-family: var(--font-details);
font-size: 2rem;
font-weight: 400;
text-align: center;
margin: 1rem 0;
}
.villageSection {
display: flex;
flex-flow: row;
width: 60rem;
align-self: center;
.villageImg {
width: 18rem;
align-self: center;
flex: 0;
}
article {
flex: 1;
padding: 0 0.5rem;
}
}
}
@media (max-width: 60rem) {
.main {
.villageSection {
flex-flow: column;
width: 96vw;
}
}
}

74
next/app/sitemap.js Normal file
View File

@@ -0,0 +1,74 @@
import getData from "@/api";
export default async function sitemap() {
// Main page information
const site = await getData("site", {
fields: ["updatedAt"],
populate: {
edition: {
fields: ["id", "updatedAt", "publishedAt"],
populate: {
programs: true,
fields: ["type", "updatedAt", "publishedAt"],
},
},
},
});
const edition = site.data?.attributes.edition.data;
const siteLastModified =
site && edition
? new Date(
Math.max(
new Date(site.data?.attributes.updatedAt),
new Date(edition.attributes.updatedAt)
)
)
: new Date();
// Programs information
const programs = (edition?.attributes.programs.data ?? []).filter(
(p) => p.attributes.publishedAt !== null
);
// Editions information
const editions = await getData("editions", { fields: ["id", "updatedAt"] });
const previousEditions = editions.data?.filter(
(e) => e.id !== edition.id && e.attributes.publishedAt !== null
);
const editionsLastModifiedTimestamp = Math.max(
...(previousEditions ?? []).map((e) => new Date(e.attributes.updatedAt))
);
const editionsLastModified =
editionsLastModifiedTimestamp === -Infinity
? siteLastModified
: new Date(editionsLastModifiedTimestamp);
return [
{
url: `${process.env.NEXT_PUBLIC_ORIGIN}`,
lastModified: siteLastModified,
changeFrequency: "yearly",
prority: 1,
},
...(programs ?? []).map((p) => ({
url: `${process.env.NEXT_PUBLIC_ORIGIN}/prog/${p.attributes.type}`,
lastModified: new Date(p.attributes.updatedAt),
changeFrequency: "yearly",
priority: 0.8,
})),
{
url: `${process.env.NEXT_PUBLIC_ORIGIN}/editions`,
lastModified: editionsLastModified,
changeFrequency: "yearly",
priority: 0.6,
},
...(previousEditions ?? []).map((e) => ({
url: `${process.env.NEXT_PUBLIC_ORIGIN}/editions/${e.id}`,
lastModified: new Date(e.attributes.updatedAt),
changeFrequency: "yearly",
priority: 0.5,
})),
];
}

63
next/app/sponsor/page.js Normal file
View File

@@ -0,0 +1,63 @@
import getData from "@/api";
import styles from "./style.module.scss";
import Link from "next/link";
import Empty from "@/components/empty";
export const dynamic = "force-dynamic";
export async function generateMetadata() {
return {
metadataBase: `${process.env.NEXT_PUBLIC_ORIGIN}`,
title: "Partenaires",
};
}
export default async function Sponsor() {
const site = await getData("site", {
populate: {
edition: {
fields: [],
populate: {
sponsors: {
fields: ["name", "uri", "image"],
populate: {
image: {
fields: ["alternativeText", "name", "url"],
},
},
},
},
},
},
});
const edition = site.data?.attributes.edition.data.attributes;
const sponsors = edition?.sponsors.data;
return (
<main className={styles.main}>
<h2>Ils nous soutiennent</h2>
{sponsors && sponsors.length > 0 ? (
<section className={styles.sponsors}>
{sponsors.map(({ id, attributes: attr }) => {
return (
<figure key={id}>
<Link target="_blank" href={attr.uri}>
<img
alt={attr.image.data.attributes.alternativeText}
src={`${process.env.NEXT_PUBLIC_IMG_URI}${attr.image.data.attributes.url}`}
/>
</Link>
<figcaption>
<Link target="_blank" href={attr.uri}>
{attr.name}
</Link>
</figcaption>
</figure>
);
})}
</section>
) : (
<Empty message="Pas de sponsors à afficher pour l'édition en cours" />
)}
</main>
);
}

View File

@@ -0,0 +1,49 @@
.main {
flex: 1 1 0%;
display: flex;
flex-flow: column;
h2 {
align-self: center;
font-family: var(--font-details);
font-size: 2rem;
font-weight: 400;
}
}
.sponsors {
display: grid;
grid-template-columns: repeat(4, 20rem);
place-items: end center;
align-self: center;
figure {
padding: 1rem 0px;
}
figcaption {
padding: 0.25rem 0px;
text-align: center;
}
img {
width: 13.5rem;
}
}
.sponsors figure:last-child {
grid-column-start: span 4;
}
@media only screen and (max-width: 70rem) {
.sponsors {
grid-template-columns: 80vw;
img {
width: calc(80vw - 20rem);
}
}
.sponsors figure:last-child {
grid-column-start: unset;
}
}

View File

@@ -0,0 +1,20 @@
import { BlocksRenderer } from "@strapi/blocks-react-renderer";
import Button from "../button";
import styles from "./style.module.scss";
export default function ArticleBlock({title, content, link, className, ...props}) {
return (
<article {...props} className={`${styles.articleBlock} ${className ?? ''}`}>
<h4>{title}</h4>
{content ? <BlocksRenderer content={content}/> : null}
<Button
type="secondary"
as="a"
href={link}
target="_blank"
>
Lire la suite
</Button>
</article>
);
}

View File

@@ -0,0 +1,17 @@
.articleBlock {
display: flex;
flex-direction: column;
h4 {
margin: 1rem 0;
}
p {
margin: 0;
}
a {
margin-top: 1rem;
align-self: flex-end;
}
}

View File

@@ -0,0 +1,28 @@
import styles from './style.module.scss';
export default function Button({as, type, children, ...props}) {
switch (as ?? 'a') {
case 'a':
return (
<a
tabIndex="0"
{...props}
className={`${styles[type ?? 'secondary']} ${styles.button} not-a-link ${props.className}`}
>
{children}
</a>
);
case 'button':
return (
<button
tabIndex="0"
{...props}
className={`${styles[type ?? 'secondary']} ${styles.button} not-a-link ${props.className}`}
>
{children}
</button>
);
}
return (null);
}

View File

@@ -0,0 +1,47 @@
.button {
appearance: none;
font-size: unset;
background: unset;
font-family: unset;
display: inline-block;
padding: 1.25rem 1.5rem;
border-radius: 2.25rem;
text-decoration: none;
transition: all 0.2s ease;
margin: 0.5rem;
}
.secondary {
color: unset;
border: solid 1px var(--fg-2);
&:hover, &:focus {
background: var(--fg-3);
border: solid 1px var(--fg-3);
box-shadow: 0 0.25rem 0.35rem rgba(0, 0, 0, 0.45);
}
&:visited {
color: unset;
}
}
.primary {
color: white;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.25);
text-decoration-color: transparent;
background: linear-gradient(175deg, var(--fefan-1) 0%, var(--fefan-1) 45%, var(--fefan-2) 55%, var(--fefan-3) 70%, var(--fefan-4) 85%);
background-size: 250% 100%;
transition: all ease var(--transition-time);
&:hover, &:focus {
text-decoration-color: var(--fefan-1);
background-position-x: 100%;
box-shadow: 0 0.25rem 0.35rem rgba(0, 0, 0, 0.45);
}
&:visited {
color: white;
}
}

View File

@@ -0,0 +1,32 @@
import ImageBlock from "@/components/imageBlock";
import StatBlock from "@/components/statBlock";
import VideoBlock from "@/components/videoBlock";
import styles from './style.module.scss';
export default async function EditionBlock({type, ...props}) {
switch (type) {
case 'stat':
return (
<article className={styles.editionBlock}>
<StatBlock {...props} />
</article>
);
case 'image':
return (
<article className={styles.editionBlock}>
<ImageBlock {...props} src={props.value} alt={props.title}/>
</article>
);
case 'video':
return (
<article className={styles.editionBlock}>
<VideoBlock {...props} src={props.value} title={props.title}/>
</article>
);
}
return null;
}

View File

@@ -0,0 +1,3 @@
.editionBlock {
overflow: hidden;
}

View File

@@ -0,0 +1,31 @@
import ImageBlock from '../imageBlock';
import EditionBlock from './editionBlock';
import styles from './style.module.scss';
export default async function EditionElement(props) {
return (
<section className={`${styles.editionSection} ${props.full ? styles.fullViewport : ''}`}>
{props.href ?
(
<a className={styles.image} href={props.href}>
<img src={props.flyerImg} alt={props.flyerAlt}/>
</a>
) : (
<ImageBlock className={styles.image} alt={props.flyerAlt} src={props.flyerImg} />
)
}
{props.blocks.slice(0, 6).map(block => (
<EditionBlock {...block} key={`${block.type}-${block.id}`} href={props.href} />
))}
{props.full ? <aside className={styles.callToAction}>
<a href={props.href}>
<h2>{props.title}</h2>
<svg width="40px" height="40px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12H18M18 12L13 7M18 12L13 17" stroke="var(--fefan-1)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</a>
</aside> : null}
</section>
);
}

View File

@@ -0,0 +1,157 @@
.editionSection {
--cell-width: 14.25rem;
display: grid;
scroll-snap-align: center;
flex: none;
grid-template-columns: repeat(4, max-content);
grid-template-rows: repeat(2, max-content);
align-content: center;
justify-content: center;
&.fullViewport {
min-height: calc(2 * var(--cell-width) + 10rem + 4 * var(--border-width));
height: calc(100vh - 2 * var(--border-width) - 5rem - 2rem);
}
.image {
display: block;
grid-row: span 2;
margin: 0.5rem;
img {
display: block;
height: calc(2 * var(--cell-width) + 1rem + 4 * var(--border-width));
}
}
img.image {
display: block;
height: calc(2 * var(--cell-width) + 1rem + 4 * var(--border-width));
}
article {
border: solid 1px var(--fg-2);
width: var(--cell-width);
height: var(--cell-width);
justify-content: center;
display: flex;
align-items: center;
object-fit: fill;
margin: 0.5rem;
}
.callToAction {
grid-row: 3;
grid-column: span 4;
justify-content: flex-end;
display: flex;
align-items: center;
& > a {
justify-content: flex-end;
display: flex;
align-items: center;
}
}
}
.informations {
color: var(--fg-0);
display: flex;
flex-flow: column;
margin-left: 1rem;
text-decoration: none;
h2 {
font-family: var(--font-details);
margin: -0.5rem 0px;
font-size: 2rem;
font-weight: 400;
}
h3 {
margin: 0px;
font-size: 0.75rem;
opacity: 0.75;
font-weight: 700;
text-transform: uppercase;
}
}
@media only screen and (max-width: 70rem) {
.editionSection {
grid-template-columns: repeat(3, max-content);
.callToAction {
grid-column: span 3;
}
article:nth-of-type(n+5) {
display: none;
}
}
}
@media only screen and (max-width: 60rem) {
.editionSection {
grid-template-columns: repeat(2, max-content);
.callToAction {
grid-column: span 2;
}
article:nth-of-type(n+3) {
display: none;
}
}
}
@media only screen and (max-width: 40rem) and (min-width: 30rem) {
.editionSection {
grid-template-columns: repeat(1, max-content);
.callToAction {
grid-column: span 1;
}
article {
display: none;
}
}
}
@media only screen and (max-width: 30rem) {
.editionSection {
--cell-width: calc(50vw - 5rem);
grid-template-columns: repeat(2, max-content);
grid-template-rows: repeat(5, max-content);
&.fullViewport {
height: calc(3.5 * var(--cell-width) + 10rem + 4 * var(--border-width));
min-height: calc(100vh - 2 * var(--border-width) - 4rem - 2rem);
}
.image {
grid-row: 1;
grid-column: span 2;
img {
height: auto;
width: calc(2 * var(--cell-width) + 1rem + 4 * var(--border-width));
}
}
img.image {
height: auto;
width: calc(2 * var(--cell-width) + 1rem + 4 * var(--border-width));
}
.callToAction {
grid-row: 5;
grid-column: span 2;
}
article:nth-of-type(n+3) {
display: none;
}
}
}

View File

@@ -0,0 +1,17 @@
import ImageBlock from "../imageBlock";
import styles from "./style.module.scss";
export default function EditionGallery({ images }) {
return (
<section className={styles.gallery}>
{images.map(({ id, attributes: attr }) => (
<div className={styles.galleryImage} key={id}>
<ImageBlock
alt={attr.alternativeText}
src={`${process.env.NEXT_PUBLIC_IMG_URI}${attr.url}`}
/>
</div>
))}
</section>
);
}

View File

@@ -0,0 +1,34 @@
.gallery {
--gallery-width: 80rem;
--gallery-columns: 3;
grid-template-columns: repeat(var(--gallery-columns), calc(var(--gallery-width) / var(--gallery-columns)));
grid-auto-rows: max-content;
align-items: center;
justify-items: center;
display: grid;
}
.galleryImage {
grid-column: span 1;
margin: 0.5rem;
max-height: calc(((6.5 / 10) * (var(--gallery-width) / var(--gallery-columns))) - 1rem);
overflow: hidden;
& > img {
width: calc((var(--gallery-width) / var(--gallery-columns)) - 1rem);
}
}
@media only screen and (max-width: 80rem) {
.gallery {
--gallery-width: 90vw;
--gallery-columns: 2;
}
}
@media only screen and (max-width: 50rem) {
.gallery {
--gallery-width: 90vw;
--gallery-columns: 1;
}
}

View File

@@ -0,0 +1,63 @@
'use client';
import qs from 'qs';
import { useEffect, useState } from "react";
import Button from "../button";
import styles from "./style.module.scss";
function fetchEmail(query) {
const queryString = qs.stringify(query);
return fetch(`${process.env.NEXT_PUBLIC_CONTENT_URI}/site?${queryString}`).then((data) => data.json());
}
export default function Email() {
const [email, setEmail] = useState("");
const [displayEmail, setDisplayEmail] = useState(false);
const [copied, setCopied] = useState(false);
const toggleEmail = () => {
if (email === "") {
fetchEmail({
fields: ['contact_mail']
}).then((res) => {
setEmail(res.data.attributes.contact_mail);
setDisplayEmail(true)
})
return;
} else if (displayEmail === false) {
setDisplayEmail(true)
} else {
navigator.clipboard.writeText(email);
setCopied(true);
}
}
useEffect(() => {
setTimeout(() => {
setDisplayEmail(false);
}, 5000);
}, [displayEmail])
useEffect(() => {
setTimeout(() => {
setCopied(false);
}, 2000);
}, [copied])
return (
<Button
as="button"
type="secondary"
onClick={toggleEmail}
className={styles.emailButton}
title={displayEmail ? "Cliquez pour copier le mail" : null}
>
<label className={displayEmail ? styles.copy : null}>
{displayEmail ? email : "Cliquez pour afficher le mail"}
</label>
<label className={copied ? styles.showLabel : styles.hideLabel}>
{copied ? "copié" : null}
</label>
</Button>
);
}

View File

@@ -0,0 +1,20 @@
.emailButton {
display: flex;
flex-direction: column;
label {
transition: all ease-in-out var(--transition-time);
}
}
.copy {
cursor: copy;
}
.showLabel {
opacity: 1;
}
.hideLabel {
opacity: 0;
}

View File

@@ -0,0 +1,15 @@
import Button from "../button";
import styles from "./style.module.scss";
export default function Empty({message, ...props}) {
return <section className={styles.empty}>
<h2 {...props}>{message ?? "Il n'y a rien ici."}</h2>
<Button
as="a"
type="secondary"
href="/"
>
Retour à la page d&apos;accueil
</Button>
</section>
}

View File

@@ -0,0 +1,9 @@
.empty {
display: flex;
flex-direction: column;
align-items: center;
h2 {
font-family: var(--font-details);
}
}

View File

@@ -0,0 +1,10 @@
import styles from './style.module.scss';
export default async function Fanfare(props) {
return (
<div className={styles.band}>
<h4>{props.name}</h4>
<h5>{props.location}</h5>
</div>
);
}

View File

@@ -0,0 +1,22 @@
.band {
padding: 0.75rem 0.25rem;
max-width: 30vw;
h4, h5 {
margin: 0px;
}
h4 {
font-family: var(--font-body);
font-weight: 500;
margin: 0px;
font-size: 1rem;
text-decoration: none;
}
h5 {
font-size: 0.75rem;
opacity: 0.75;
font-weight: 700;
text-transform: uppercase;
}
}

View File

@@ -0,0 +1,14 @@
import Link from "next/link";
import styles from "./style.module.scss";
export default async function Footer() {
return (
<footer className={styles.footer}>
<Link href="/legal">Mentions légales</Link>
<span className={styles.separator}> · </span>
<Link href="/sponsor">Ils nous soutiennent</Link>
<span className={styles.separator}> · </span>
<Link href="/contact">Contact</Link>
</footer>
);
}

View File

@@ -0,0 +1,13 @@
.footer {
border-top: var(--fg-3) solid var(--border-width);
text-align: center;
padding: 0.25rem;
span {
white-space: nowrap;
}
a {
}
}

View File

@@ -0,0 +1,34 @@
import getData from '@/api';
import NavigationDrawer from './navigationDrawer';
export default async function Header() {
const site = await getData('site', {
populate: {
edition: {
fields: [],
populate: {
programs: {
fields: ['type', 'title'],
}
}
}
}
});
const editionId = site.data?.attributes.edition.data?.id;
const programs = editionId ? site.data.attributes.edition.data.attributes.programs.data : null
const socialLinkQuery = editionId ? {
filters: {
$or: [
{ edition: { id: { $null: true } } },
{ edition: { id: { $eq: editionId } } },
]
}
} : {
filters: { edition: { id: { $null: true } } },
}
const socialLinks = await getData('social-links', socialLinkQuery)
const links = socialLinks.data ?? []
return (
<NavigationDrawer programs={programs} links={links}/>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import SocialIcon from "../social-icon";
import { useState } from "react";
import styles from "./style.module.scss";
export default function NavigationDrawer({programs, links , ...props}) {
const [isOpen, setIsOpen] = useState(false);
const toggleDrawer = () => {
setIsOpen(prev => !prev);
}
return (
<header className={`${styles.header} ${isOpen ? styles.drawerOpen : ""}`}>
<div className={`${styles.backdrop}`} onClick={toggleDrawer}/>
<button className={`${styles.social} ${styles.burgerIcon}`} onClick={toggleDrawer}>
<svg className={`feather feather-menu`} xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#1B519E" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
</button>
<Link href="/" className={`not-a-link ${styles.logo}`}>
<img src="/fefan.png" alt="Fefan" />
<h1>Festival de fanfare</h1>
</Link>
<a className={`${styles.social} ${styles.programIcon}`} href="/prog/city-wide">
<svg className={`feather feather-calendar`} xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#1B519E" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
</a>
<nav className={`${styles.navigation}`}>
<div className={styles.spacer} />
<Link href="/">Accueil</Link>
{programs ? programs.map(({ id, attributes: attr }) => (
<Link href={`/prog/${attr.type}`} key={id}>{attr.title}</Link>
)): null}
<Link href="/editions">Éditions précédentes</Link>
<Link href="/contact">Contact</Link>
<div className={styles.spacer} />
<nav className={styles.socialLinks}>
{links.map(({ id, attributes: attr }) => (
<Link aria-label={attr.type} target="_blank" href={attr.uri} key={id} className={`${styles.social} not-a-link`}>
<SocialIcon name={attr.type} />
</Link>
))}
</nav>
</nav>
</header>
);
}

View File

@@ -0,0 +1,156 @@
.header {
display: flex;
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 200;
flex-flow: row;
border-bottom: var(--fg-3) solid var(--border-width);
background: var(--bg-0);
}
.header + * {
margin-top: calc(5rem + var(--border-width));
}
.logo {
display: flex;
margin: 0.5rem 1rem;
flex-flow: column;
text-decoration: none;
text-align: right;
color: var(--fefan-4);
h1 {
font-family: var(--font-details);
font-weight: 500;
font-size: 0.75rem;
margin: -0.10rem 0.35rem;
}
img:first-child {
height: 3rem;
}
}
.social {
appearance: none;
border: none;
background: unset;
font-size: unset;
margin: 0;
border-radius: 100%;
padding: 0.5rem;
text-decoration: none;
color: var(--fefan-1);
display: flex;
align-self: center;
justify-content: center;
box-shadow: inset 0 0 6px 3px var(--background);
transition: all 0.2s ease;
}
.spacer {
flex: 1;
}
.spacer:first-of-type {
display: none;
}
.navigation {
display: flex;
align-items: center;
flex: 1;
a {
margin: 0 0.35rem;
}
.socialLinks {
align-items: center;
display: flex;
margin-right: 1rem;
justify-content: flex-end;
}
}
.backdrop {
position: fixed;
content: '';
top: 0;
left: 0;
right: 0;
bottom: 100vh;
z-index: 250;
background: rgba(0, 0, 0, 0);
transition: background-color 0.2s ease;
}
.burgerIcon {
display: none;
align-self: center;
cursor: pointer;
}
.drawerOpen .navigation {
left: 0%;
}
.drawerOpen .backdrop {
bottom: 0vh;
background: rgba(0, 0, 0, 0.25);
}
.programIcon {
display: none;
}
@media only screen and (max-width: 992px) {
.header {
justify-content: space-between;
padding: 0 1.5rem;
}
.burgerIcon {
display: block;
}
.programIcon {
display: block;
}
.navigation {
padding: 1rem 0;
flex-flow: column;
position: fixed;
z-index: 300;
align-items: stretch;
top: 0;
max-width: 80vw;
width: 20rem;
left: -100%;
bottom: 0;
background: var(--bg-0);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.25);
transition: all 0.2s ease;
a {
padding: 0.5rem;
text-align: left;
}
.spacer:first-of-type {
display: block;
}
.socialLinks {
justify-content: space-around;
}
}
.drawerOpen {
left: 0%;
}
}

View File

@@ -0,0 +1,38 @@
export default function SocialIcon(props) {
if (props.name === 'youtube')
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="36" height="36" viewBox="0 0 24 24"
fill="none" stroke="currentColor"
strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"
>
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z" />
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02" />
</svg>
);
else if (props.name === 'instagram')
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="36" height="36" viewBox="0 0 24 24"
fill="none" stroke="currentColor"
strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"
>
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
</svg>
);
else if (props.name === 'facebook')
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="36" height="36" viewBox="0 0 24 24"
fill="none" stroke="currentColor"
strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"
>
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
</svg>
);
}

View File

View File

@@ -0,0 +1,33 @@
'use client';
import { useState } from 'react';
import styles from './style.module.scss';
import ImagePopup from '../imagePopup';
export default function ImageBlock({alt, src, ...props}) {
const [showPopup, setShowPopup] = useState(false);
const toggleShowPopup = () => {
setShowPopup(prev => !prev);
}
return (
<>
<img
{...props}
onClick={toggleShowPopup}
src={src}
alt={alt}
className={`${styles.imageBlock} ${props.className}`}
/>
{
showPopup ?
<ImagePopup
toggleShowPopup={toggleShowPopup}
alt={alt}
src={src}
/>
: null
}
</>
);
}

View File

@@ -0,0 +1,4 @@
.imageBlock {
cursor: pointer;
max-height: 100%;
}

View File

@@ -0,0 +1,32 @@
import styles from './style.module.scss';
export default function ImagePopup({toggleShowPopup, src, alt, ...props}) {
return (
<div className={styles.modal}>
<button onClick={toggleShowPopup} className={styles.close}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-x"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div onClick={toggleShowPopup} className={styles.imgModal} />
<img
{...props}
src={src}
alt={alt}
className={styles.popupImage}
/>
</div>
);
}

View File

@@ -0,0 +1,92 @@
.modal {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 220;
display: flex;
align-items: center;
overflow: scroll;
overscroll-behavior: contain;
cursor: pointer;
& > div {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: var(--fg-0);
opacity: 0.65;
z-index: 220;
}
}
.popupImage {
margin: auto;
display: block;
max-width: 80vw;
max-height: 80vh;
animation-name: zoom;
animation-duration: 0.6s;
animation-fill-mode: both;
z-index: 230;
cursor: auto;
}
.close {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
top: 3.6rem;
right: 4rem;
height: 3rem;
width: 3rem;
padding: 0.25rem;
appearance: none;
border: unset;
font-size: unset;
font-family: unset;
color: var(--bg-0);
background: var(--fg-0-translucid);
font-weight: bold;
transition: 0.3s;
cursor: pointer;
border-radius: 100%;
z-index: 230;
svg {
height: 3rem;
width: 3rem;
}
}
.close:hover {
top: 3.2rem;
right: 3.6rem;
height: 3.8rem;
width: 3.8rem;
svg {
height: 3.8rem;
width: 3.8rem;
}
}
@keyframes zoom {
from {
transform:scale(0);
box-shadow: 0 0 0.2rem rgba(0,0,0,0.5);
}
to {
transform:scale(1);
box-shadow: 0 0 1rem rgba(0,0,0,0.5);
}
}

View File

@@ -0,0 +1,25 @@
import ArticleBlock from "../articleBlock";
import ImageBlock from "../imageBlock";
import styles from "./style.module.scss";
export default function PressBlock({
left: {type: leftType, ...left},
right: {type: rightType, ...right},
...props
}) {
return (
<section {...props} className={styles.pressBlock}>
{
leftType ? leftType === 'article' ?
<ArticleBlock {...left} className={styles.pressArticle}/> :
<ImageBlock {...left} className={styles.pressImage}/> : null
}
{
rightType ? rightType === 'article' ?
<ArticleBlock {...right} className={styles.pressArticle}/> :
<ImageBlock {...right} className={styles.pressImage}/> : null
}
</section>
);
}

View File

@@ -0,0 +1,36 @@
.pressBlock {
--element-width: 30vw;
display: flex;
.pressArticle, .pressImage {
width: var(--element-width);
margin: 1rem;
}
.pressImage {
align-self: center;
}
&:nth-of-type(2n+1) {
flex-flow: row-reverse;
}
}
@media only screen and (max-width: 80rem) {
.pressBlock {
--element-width: 45vw;
.pressImage {
width: calc(var(--element-width) - 10vw);
}
}
}
@media only screen and (max-width: 60rem) {
.pressBlock {
--element-width: 90vw;
&, &:nth-child(2n) {
flex-flow: column;
}
}
}

View File

@@ -0,0 +1,10 @@
import styles from './style.module.scss';
export default async function StatBlock(props) {
return (
<a href={props.href} className={styles.statBox}>
<span>{props.value}</span>
<h3>{props.title}</h3>
</a>
);
}

View File

@@ -0,0 +1,33 @@
.statBox {
width: 100%;
justify-content: center;
align-items: center;
text-align: center;
display: flex;
flex-direction: column;
text-decoration: none;
transition: none;
&:hover {
text-decoration: none;
}
span {
font-size: 4rem;
color: var(--fg-0);
}
h3 {
padding: 0px;
margin: 0px;
color: var(--fg-1);
}
}
@media only screen and (max-width: 30rem) {
.statBox {
span {
font-size: 2.2rem;
}
}
}

View File

@@ -0,0 +1,23 @@
import { BlocksRenderer } from '@strapi/blocks-react-renderer';
import styles from './style.module.scss';
export default async function TimeSlot(props) {
const date_begin = new Date(props.start)
const date_end = new Date(props.end)
const date_format = new Intl.DateTimeFormat('fr', {
weekday: 'long',
month: "long",
day: "numeric"
}).format(date_begin)
return (
<article className={styles.timeSlot}>
<h4>
{date_format} - {date_begin.getHours()}h
{date_begin.getMinutes() !== 0 ? date_begin.getMinutes() : ""} à&nbsp;
{date_end.getHours()}h{date_end.getMinutes() !== 0 ? date_end.getMinutes() : ""}
</h4>
<BlocksRenderer content={props.content}/>
</article>
);
}

View File

@@ -0,0 +1,17 @@
.timeSlot {
h4::first-letter {
text-transform: capitalize;
}
h4 {
font-family: var(--font-details);
margin: 0px;
font-size: 1.5rem;
font-weight: 400;
color: var(--fefan-3);
}
margin-bottom: 2rem;
padding-right: 1rem;
padding-left: 1rem;
}

View File

@@ -0,0 +1,15 @@
import styles from "./style.module.scss";
export default async function VideoBlock(props) {
return (
<iframe
className={`${props.className} ${styles.videoBlock}`}
width={ props.mode ==='thumbnail' ? "220" : "560" }
height={props.mode ==='thumbnail' ? "220" : "315"}
src={props.src}
title={props.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
);
}

View File

@@ -0,0 +1,3 @@
.videoBlock {
max-width: 90vw;
}

7
next/jsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}

6
next/next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

23
next/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "fefan-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@strapi/blocks-react-renderer": "^1.0.0",
"next": "14.0.0",
"qs": "^6.11.2",
"react": "^18",
"react-dom": "^18",
"sass": "^1.69.5"
},
"devDependencies": {
"eslint": "^8",
"eslint-config-next": "14.0.0"
}
}

BIN
next/public/fefan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

2191
next/yarn.lock Normal file

File diff suppressed because it is too large Load Diff