Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
37 changes: 37 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,28 @@
# 27W Coding Task

### checklist

1. blur backdrop header ✅
2. colored text gradients ✅
3. header logo ✅
4. footer content? ❌
5. fetch dealers ✅
6. fetch specific post ✅
7. add fonts ✅
8. add all images ✅
9. add custom classes via @apply for inputs ☑️ (just added a const of classnames) ✅
10. footer socials ❌
11. button spacings ✅
12. input spacings ✅
13. yellow active nav ✅
14. cache / revalidate ✅
15. 'You might also like' section ✅

# Notes

1. Just to show different headers with username or button, conditionally showing via pathname. Usually would do via auth user or session, but wanted to show both headers
2. Added zod validation for the client, zod checks first, preventing anything to be sent until all conditions match. Custom errors included
3. Mapped over all slugs and added to /posts (left this page unstyled)
4. Added a basic loading state (did not add any Suspense wrappers, usually would along with some skeletons and non-fetched data)
5. Skipped out on extended footer with links/images. Did not seem necessary for the focus of the task
6. Hope you like it! If you would like for me to make any changes please let me know
11 changes: 11 additions & 0 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use client";

import React from "react";

export default function Error() {
return (
<div className="flex-center min-h-[80dvh] text-white">
<h1 className="text-4xl font-bold">ERROR</h1>
</div>
);
}
Binary file added app/favicon.ico
Binary file not shown.
33 changes: 33 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}

body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}

@layer utilities {
.text-balance {
text-wrap: balance;
}
}

.flex-center {
@apply flex items-center justify-center;
}

.flex-center-col {
@apply flex items-center justify-center flex-col;
}
37 changes: 37 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TopBanner from "@/components/TopBanner";
import "../styles/fonts.css";

export const metadata: Metadata = {
title: "Radical Motorsport",
description: "27.works",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full">
<body className={`h-full font-futura`}>
<div className="min-h-screen flex flex-col">
{/* <TopBanner /> */}
<div className="flex-grow">
<div className="">
<div className="sticky top-0 z-50 backdrop-blur-md bg-gradient-to-b from-black/70 to-black/30">
<Header />
</div>

<div className="flex-grow mx-auto">{children}</div>
</div>
</div>
<Footer />
</div>
</body>
</html>
);
}
10 changes: 10 additions & 0 deletions app/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";

export default function Loading() {
return (
<div className="flex-center min-h-[80dvh]">
<div className="w-4 h-4 border-2 border-[#F2CB13] border-l-transparent rounded-full animate-spin"></div>
<span className="ml-2 text-[#F2CB13]">Loading...</span>
</div>
);
}
9 changes: 9 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react";

export default function NotFound() {
return (
<div className="flex-center min-h-[80dvh] text-white">
<h1 className="text-4xl font-bold">404 NOT FOUND</h1>
</div>
);
}
21 changes: 21 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Link from "next/link";

export default async function Home() {
return (
<main className="flex-center-col min-h-[80dvh]">
<p>
Please head over to the
<Link className="underline text-[#F2CB13]" href="/posts">
{" "}
NEWS (articles/posts)
</Link>
</p>
<p>Or</p>
<p>
<Link className="underline text-[#F2CB13]" href="/profile">
MY RADICAL
</Link>
</p>
</main>
);
}
10 changes: 10 additions & 0 deletions app/posts/[slug]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";

export default function PostsSlugLoading() {
return (
<div className="flex-center min-h-[80dvh]">
<div className="w-4 h-4 border-2 border-[#F2CB13] border-l-transparent rounded-full animate-spin"></div>
<span className="ml-2 text-[#F2CB13]">Loading...</span>
</div>
);
}
165 changes: 165 additions & 0 deletions app/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import React from "react";
import { notFound } from "next/navigation";
import H1Gradient from "@/components/ui/H1Gradient";
import Image from "next/image";
import Link from "next/link";
import parse from "html-react-parser";
import { Bookmark, Share2 } from "lucide-react";
import AlsoLike from "@/components/AlsoLike";

export default async function PostsSlugPage({
params,
}: {
params: { slug: string };
}) {
const { slug } = params;

const response = await fetch(
`https://caruuto.27.works/api/v1/posts?filter={"slug":"${slug}"}`,
{
headers: {
Authorization: `Api-key-v1 ${process.env.APIKEY}`,
},

next: {
revalidate: 300,
},
}
);

if (!response.ok) {
throw new Error("Failed to fetch data");
}

const data = await response.json();

if (!data.results || data.results.length === 0) {
notFound();
}

const post = data.results[0];

const getSection = (type: string) =>
post.sections.find((s: any) => s.content_type === type);

const formatDate = (dateString: string) => {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return new Date(dateString).toLocaleDateString("en-US", options);
};

return (
<div className="relative">
<div className="relative w-full h-[700px]">
<Image
src="/images/article_hero.jpg"
alt={post.title}
layout="fill"
objectFit="cover"
quality={100}
className="opacity-50"
priority
/>
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-black to-transparent"></div>
<div className="absolute bottom-0 left-0 right-0 h-64 bg-gradient-to-t from-black to-transparent"></div>
</div>

<div className="relative max-w-[820px] mx-auto -mt-48 text-white px-4 space-y-7 pb-20">
{/* top section, home, latest, title summary */}
<section className="max-w-[715px] ">
<p className=" text-[#F2CB13] font-futura-book text-[14px]">
<Link href="/" className=" underline">
Home
</Link>{" "}
<span>{" / "}</span>
<Link href="/posts" className="text-[#F2CB13] underline">
Latest News
</Link>{" "}
</p>
<div className="max-w-[715px] pt-[11px] pb-[19px]">
<h1 className="text-5xl font-futura-bold text-[#F2CB13]">
{parse(post.title?.toUpperCase() || "Untitled")}
</h1>
</div>
{getSection("subtitle") && (
<p className="text-xl max-w-[680px]">
{parse(getSection("subtitle")?.content || "")}
</p>
)}
</section>
{/* avatar, name, date, 2 icons */}
<section className="flex items-center justify-between max-w-[680px] ">
<div className="flex items-center">
<div className="flex items-center space-x-3 mr-3">
<Image
src="/images/article_author.jpg"
alt="Author"
width={42}
height={42}
className="rounded-full"
/>
<span className="text-[16px] font-futura-book">
{post.createdBy || "John Smith"}
</span>
</div>
<span className="text-[#D9D9D9]">•</span>
<div className="text-[16px] ml-3">
<span className="text-[16px] font-futura-book">
{formatDate(post.created_at)}
</span>
</div>
</div>

<div className="flex space-x-2 text-[#B1B3B3]">
<span className="border border-[#B1B3B3] rounded-full p-1.5">
<Bookmark className="w-4 h-4 " />
</span>
<span className="border border-[#B1B3B3] rounded-full p-1.5">
{" "}
<Share2 className="w-4 h-4" />
</span>
</div>
</section>
{/* Content sections */}
{post.sections.map((section: any, index: number) => {
switch (section.content_type) {
case "bodyText":
return (
<section
key={index}
className="max-w-[680px] text-[18px] font-futura-book text-[#B1B3B3] space-y-7"
>
{parse(section.content || "")}
</section>
);
case "pullQuote":
return (
<section key={index} className="max-w-[680px] ">
<div className=" border-l-[5px] border-[#F2CB13] pl-[25px]">
<H1Gradient className="font-futura-bold text-[24px] ">
"{section.content.toUpperCase()}"
</H1Gradient>
{section.author && (
<p className="mt-[7px] text-[#B1B3B3] text-[14px] font-futura-book">
{section.author}
</p>
)}
</div>
</section>
);

default:
return null;
}
})}
</div>
<hr className="border-white/20 max-w-7xl mx-auto" />
<div className="px-4 md:ml-[100px] pt-20 ">
<AlsoLike />
</div>
</div>
);
}
Loading