Compare commits

...

20 Commits

Author SHA1 Message Date
ea12adf484 added case-study slug framework
All checks were successful
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Successful in 1m10s
2026-06-22 19:24:13 +01:00
ef8d90651e update tagline, heading and force darkmode
All checks were successful
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Successful in 1m44s
2026-06-22 16:42:08 +01:00
1dd1ff893e added passkey to ssh-action
All checks were successful
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Successful in 1m3s
2026-06-05 07:42:51 +01:00
d073e021f3 added gitea username to docker image push location
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 55s
2026-06-04 22:21:45 +01:00
c78bd0188b hardcoding push action tags to be lowercase
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 1m7s
2026-06-04 22:13:46 +01:00
cff329a4c4 first fill deploy test with new runner container
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 1m50s
2026-06-04 22:06:50 +01:00
f40e8c638c test login-action
All checks were successful
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Successful in 49s
2026-06-04 20:31:07 +01:00
575fa686ff Remove default readme content
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 22s
2026-06-02 07:10:17 +00:00
5ecfc5bb45 changed version of login-action
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 2m2s
2026-06-01 20:21:27 +01:00
8c46bde9ce updated workflows
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 2m8s
2026-06-01 19:58:35 +01:00
aa163c6c6e testing new gitea workflow
Some checks failed
Build and Deploy NextJS SculptedArc Website / build-and-deploy (push) Failing after 2s
2026-06-01 19:02:58 +01:00
68425fe793 set default shell to sh as bash not found in
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
2026-06-01 16:24:58 +01:00
21387170f1 add test gitea Action
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 10s
2026-06-01 16:18:56 +01:00
92e3de7340 updated next config to output standalone 2026-06-01 14:39:28 +01:00
bb008f7f8f add a Dockerfile so app can be containerized 2026-06-01 14:38:30 +01:00
cfebb4cee5 added favicon and populated template text with real text 2026-05-31 21:50:04 +01:00
f67093f34e add servies page with content and a typing words feature to contact page 2026-05-30 08:51:05 +01:00
87f8d2b04e fixed carousel 2026-05-30 07:20:39 +01:00
74a908f07f improved site header, added contact form, content card carousel, footer, and more photos 2026-05-28 21:20:47 +01:00
6a579699e7 added .npmrc file to allow build from depencencies from sharp and unrs-resolver 2026-05-27 21:39:15 +01:00
56 changed files with 2995 additions and 447 deletions

135
.dockerignore Normal file
View File

@@ -0,0 +1,135 @@
############################################################
# Production-ready .dockerignore for a Next.js (Vercel-style) app
# Keeps Docker builds fast, lean, and free of development files.
############################################################
# Dependencies (installed inside Docker, never copied)
node_modules/
.pnpm-store/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Next.js build outputs (always generated during `next build`)
.next/
out/
dist/
build/
.vercel/
# Tests and testing output (not needed in production images)
coverage/
.nyc_output/
__tests__/
__mocks__/
jest/
cypress/
cypress/screenshots/
cypress/videos/
playwright-report/
test-results/
.vitest/
vitest.config.*
jest.config.*
cypress.config.*
playwright.config.*
*.test.*
*.spec.*
# Local development and editor files
.git/
.gitignore
.gitattributes
.vscode/
.idea/
*.swp
*.swo
*~
*.log
# Environment variables (only commit template files)
.env
.env*.local
.env.development
.env.test
.env.production.local
# Docker configuration files (not needed inside build context)
Dockerfile*
.dockerignore
compose.yaml
compose.yml
docker-compose*.yaml
docker-compose*.yml
# Documentation
*.md
docs/
# CI/CD configuration files
.github/
.gitlab-ci.yml
.gitea/
.travis.yml
.circleci/
Jenkinsfile
# Cache directories and temporary data
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.swc/
.turbo/
.tmp/
.temp/
# TypeScript build metadata
*.tsbuildinfo
# Sensitive or unnecessary configuration files
*.pem
.editorconfig
.prettierrc*
prettier.config.*
.eslintrc*
eslint.config.*
.stylelintrc*
stylelint.config.*
.babelrc*
*.iml
*.ipr
*.iws
# OS-specific junk
.DS_Store
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# AI/ML tool metadata and configs
.cursor/
.cursorrules
.copilot/
.copilotignore
.github/copilot/
.gemini/
.anthropic/
.kiro
.claude
AGENTS.md
.agents/
# AI-generated temp files
*.aider*
*.copilot*
*.chatgpt*
*.claude*
*.gemini*
*.openai*
*.anthropic*

View File

@@ -0,0 +1,55 @@
name: Build and Deploy NextJS SculptedArc Website
on: [push]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# 1. Log into your self-hosted gitea container storage layer
- name: Log in to gitea Container Registry
uses: docker/login-action@v4
with:
registry: gitea.sculpted-arc.co.uk # Change to your gitea domain
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN_GITEA }} # Store a personal access token in your repo secrets for security
# 2. Build the Dockerfile and upload the compiled app image
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: gitea.sculpted-arc.co.uk/james-admin/sculptedarcwebsite:latest
# 3. Securely SSH to the host using masked secrets
- name: Deploy to Server via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
port: ${{ secrets.SSH_PORT }}
username: james
key: ${{ secrets.SSH_PRIVATE_KEY }} # Completely invisible to anyone viewing code
passphrase: ${{ secrets.SSH_PASSPHRASE }}
script: |
cd /home/james/docker/sculpted-arc-site
# Authenticate the host machine to pull from gitea's registry
docker login -u ${{ gitea.actor }} -p ${{ secrets.TOKEN_GITEA }} gitea.sculpted-arc.co.uk
# Pull down the fresh image we just built and restart gracefully
docker compose pull
docker compose up -d --remove-orphans

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
allowed-build-from-dependencies[]=sharp
allowed-build-from-dependencies[]=unrs-resolver

112
Dockerfile Normal file
View File

@@ -0,0 +1,112 @@
# ============================================
# Stage 1: Dependencies Installation Stage
# ============================================
# IMPORTANT: Node.js Version Maintenance
# This Dockerfile uses Node.js 24.13.0-slim, which was the latest LTS version at the time of writing.
# To ensure security and compatibility, regularly update the NODE_VERSION ARG to the latest LTS version.
ARG NODE_VERSION=24.13.0-slim
FROM node:${NODE_VERSION} AS dependencies
# Set working directory
WORKDIR /app
# Copy package-related files first to leverage Docker's caching mechanism
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* pnpm-workspace.yaml ./
# Install project dependencies with frozen lockfile for reproducible builds
RUN --mount=type=cache,target=/root/.npm \
--mount=type=cache,target=/usr/local/share/.cache/yarn \
--mount=type=cache,target=/root/.local/share/pnpm/store \
if [ -f package-lock.json ]; then \
npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm install --frozen-lockfile; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 2: Build Next.js application in standalone mode
# ============================================
FROM node:${NODE_VERSION} AS builder
# Set working directory
WORKDIR /app
# Copy project dependencies from dependencies stage
COPY --from=dependencies /app/node_modules ./node_modules
# Copy application source code
COPY . .
ENV NODE_ENV=production
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js application
# If you want to speed up Docker rebuilds, you can cache the build artifacts
# by adding: --mount=type=cache,target=/app/.next/cache
# This caches the .next/cache directory across builds, but it also prevents
# .next/cache/fetch-cache from being included in the final image, meaning
# cached fetch responses from the build won't be available at runtime.
RUN if [ -f package-lock.json ]; then \
npm run build; \
elif [ -f yarn.lock ]; then \
corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then \
corepack enable pnpm && pnpm build; \
else \
echo "No lockfile found." && exit 1; \
fi
# ============================================
# Stage 3: Run Next.js application
# ============================================
FROM node:${NODE_VERSION} AS runner
# Set working directory
WORKDIR /app
# Set production environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the run time.
ENV NEXT_TELEMETRY_DISABLED=1
# Copy production assets
COPY --from=builder --chown=node:node /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown node:node .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
# If you want to persist the fetch cache generated during the build so that
# cached responses are available immediately on startup, uncomment this line:
# COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache
# Switch to non-root user for security best practices
USER node
# Expose port 3000 to allow HTTP traffic
EXPOSE 3000
# Start Next.js standalone server
CMD ["node", "server.js"]

View File

@@ -1,36 +1 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). This is the source code for my sculpted arc website. More documentation will be written soon
## 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.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## 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/app/building-your-application/deploying) for more details.

View File

@@ -1,5 +1,16 @@
import PicWithText from "@/components/pic-with-text";
import heda_bang from "../../assets/u_mey4kjj5ww-angry-2498.gif"
export default function Page() { export default function Page() {
const picWithTextTop = {
title: "About Us",
text: "Founed in 2025, Sculpted Arc is a web development company dedicated to helping small to medium-sized businesses establish and grow their online presence. We not only specialize in creating custom websites but also offer a wide range of services and support, including e-commerce solutions, business automation, and long-term maintenance.",
src: heda_bang.src,
reverse: false
}
return ( return (
<h1>about us page</h1> <PicWithText {...picWithTextTop}/>
) )
} }

View File

@@ -0,0 +1,32 @@
// src/app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/case-study';
import { notFound } from 'next/navigation';
import parse from 'html-react-parser';
interface StudyPageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPostPage({ params }: StudyPageProps) {
// In Next.js 15+, params is a Promise and must be awaited
const { slug } = await params;
const caseStudy = await getPostBySlug(slug);
if (!caseStudy) {
notFound(); // Triggers the closest 404 page if the file doesn't exist
}
return (
<article className="max-w-2xl mx-auto py-10 px-4">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-2">{caseStudy.title}</h1>
</header>
{/* Render the parsed HTML safely */}
<div className="prose dark:prose-invert">
{parse(caseStudy.contentHtml)}
</div>
</article>
);
}

View File

@@ -1,5 +1,23 @@
export default function Page() { import ContactForm from '@/components/contact-form';
import TypingWords from '@/components/typing-words';
export default function ContactPage() {
return ( return (
<h1>contact page</h1> <div className="flex w-full flex-col gap-10 lg:flex-row lg:items-start lg:gap-12 xl:gap-16">
) <div className="w-full lg:w-1/2 lg:max-w-xl">
<h1 className="mb-4 text-4xl font-bold leading-tight text-heading sm:text-5xl md:text-6xl">
Tell us about your
</h1>
<h1 className="mb-4 text-4xl font-bold leading-tight text-heading sm:text-5xl md:text-6xl">
<TypingWords />
</h1>
<p className="text-lg text-body lg:text-xl">
Share a few details and we&apos;ll get back to you as soon as we can.
</p>
</div>
<div className="w-full lg:w-1/2 lg:flex lg:justify-end">
<ContactForm />
</div>
</div>
);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -19,6 +19,10 @@
} }
} }
html {
scroll-padding-top: 100px;
}
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Menubar from '../components/menubar'; import SiteHeader from '../components/site-header';
import Footer from "@/components/footer";
import favicon from "../assets/scws-logo-no-bg.png"
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -14,8 +16,13 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Sculpted Arc",
description: "Generated by create next app", description: "Web and software solutions for companies and people around the world",
};
export const viewport = {
width: "device-width",
initialScale: 1,
}; };
export default function RootLayout({ export default function RootLayout({
@@ -28,9 +35,18 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col dark:bg-black"> <head>
<Menubar /> <link rel="icon" href={favicon.src} />
</head>
<body className="min-h-full flex flex-col dark:bg-black dark">
<SiteHeader />
<div className="flex min-h-screen flex-col bg-zinc-50 font-sans dark:bg-black">
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col items-center justify-between bg-white px-8 py-32 dark:bg-black sm:items-start">
{children} {children}
</main>
</div>
<Footer />
</body> </body>
</html> </html>
); );

View File

@@ -1,26 +1,64 @@
import Image from "next/image";
import HeroSection from "@/components/hero-section"; import HeroSection from "@/components/hero-section";
import ContentCard from "@/components/ui/content-cards"; import ContentCardCarousel from "@/components/content-card-carousel";
import Footer from "@/components/footer";
import PicWithText from "@/components/pic-with-text"; import PicWithText from "@/components/pic-with-text";
import pictureOne from "../assets/hero-picture-1.png" import pictureOne from "../assets/hero-picture-1.png"
import pictureTwo from "../assets/hero-picture-2.png" import pictureTwo from "../assets/hero-picture-2.png"
import pictureThree from "../assets/hero-picture-3.png" import pictureThree from "../assets/hero-picture-3.png"
import pictureFour from "../assets/BrickslipsWebPage.png"
import heroPicture from "../assets/mxj-files-planet-27066.gif"
import logo from "../assets/milaoktasafitri-astronomy-9746537_1920.png"
export default function Home() { export default function Home() {
return ( const contentCards = [
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> {
<main className="flex flex-1 w-full max-w-7xl flex-col items-center justify-between py-32 px-8 bg-white dark:bg-black sm:items-start"> heading: "Michala's Art Gallery",
card_text: "This case study was for a small business selling artwork",
button_text: "Read More",
src: pictureOne.src
},
{
heading: "Athlito",
card_text: "This was an Ecommerce padel shop",
button_text: "Read More",
src: pictureTwo.src
},
{
heading: "Birchover Reclamation",
card_text: "This case study was for a stone yard company",
button_text: "Read More",
src: pictureThree.src
},
{
heading: "Brickslips Direct LTD",
card_text: "This case study was for a small business selling brickslips",
button_text: "Read More",
src: pictureFour.src
}
]
<HeroSection /> const picWithText = {
<div className="flex flex-row flex-wrap justify-center m-auto"> title: "Hand crafted software, built for scale",
<ContentCard heading="Michala Art Shop" card_text="This case study was for a small business selling artwork" button_text="click me" src={pictureOne.src}/> text: "Over the last year, we've had the privilege of working with a diverse range of clients, from local artisans to growing enterprises. Each project has been an opportunity to learn, innovate, and create something truly unique. Our commitment to quality and customer satisfaction has earned us a reputation for excellence in the web development industry.",
<ContentCard heading="Athlito" card_text="This was an Ecommerce padel shop" button_text="click me" src={pictureTwo.src}/> src: logo.src,
<ContentCard heading="Birchover Reclamation" card_text="This case study was for a stone yard company" button_text="click me" src={pictureThree.src}/> reverse: false
<ContentCard heading="Michala Art Shop" card_text="This case study was for a small business selling artwork" button_text="click me" src={pictureOne.src}/> }
</div>
<PicWithText /> const HeroSectionProps = {
</main> description: "Sculpted Arc Web Solutions is a web development company. Our passion ranges from creating stunning websites to developing innovative digital solutions. We would love to hear more about your project",
buttonText: "Tell us about your project",
imageSrc: heroPicture.src
}
return (
<>
<HeroSection {...HeroSectionProps} />
<PicWithText {...picWithText} />
<div className="w-full m-auto">
<ContentCardCarousel cards={contentCards} />
</div> </div>
</>
) )
} }

View File

@@ -1,5 +0,0 @@
export default function Page() {
return (
<h1>plans page</h1>
)
}

16
app/services/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import ServicesFeature from '@/components/services-feature';
export default function ServicesPage() {
return (
<>
<h1 className="mb-4 text-5xl font-bold text-heading md:text-6xl">
Our <span className="text-transparent bg-clip-text bg-gradient-to-r from-sky-400 to-violet-800">Services</span>
</h1>
<p className="mb-12 max-w-2xl text-lg text-body lg:text-xl">
Sculpted Arc helps companies build, launch, and grow onlinefrom first design
through long-term support. We offer a wide range of services to meet your needs, including custom web development, e-commerce solutions, content management systems, and more...
</p>
<ServicesFeature />
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
assets/saws.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
assets/scws-logo-no-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,252 +0,0 @@
/* app/Menubar.css */
.menubar {
position: sticky;
top: 20px;
z-index: 1000;
display: flex;
justify-content: center;
align-self: center;
width: calc(100% - 40px);
min-width: 720px;
max-width: 1000px;
margin: 20px auto 0;
border-radius: 30px;
background-color: rgba(29, 29, 29, 0.9);
backdrop-filter: blur(10px);
padding: 10px 20px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(60, 60, 60, 0.3);
isolation: isolate;
transition: padding 0.35s ease;
}
.menubar::before {
content: '';
position: absolute;
inset: -5px;
background: linear-gradient(45deg, #ff00ff, #00ffff);
filter: blur(25px);
border-radius: 20px;
z-index: 0;
opacity: 0.6;
pointer-events: none;
}
.menubar-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
width: 100%;
max-width: 960px;
transition: flex-direction 0.35s ease;
}
.desktop-menu {
display: flex;
gap: 30px;
}
.menu-item {
color: #e0e0e0;
text-decoration: none;
font-size: 1.1em;
padding: 5px 10px;
transition: color 0.3s ease, transform 0.3s ease;
}
.menu-item:hover {
color: #ffffff;
transform: translateY(-2px);
}
.mobile-menu {
display: none;
width: 100%;
position: relative;
}
.mobile-menu-checkbox {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.burger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #e0e0e0;
font-size: 2em;
line-height: 1;
padding: 5px 10px;
position: relative;
z-index: 3;
touch-action: manipulation;
width: 1.4em;
height: 1.4em;
cursor: pointer;
}
.burger-icon-menu,
.burger-icon-close {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.burger-icon-menu {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.burger-icon-close {
opacity: 0;
transform: scale(0.8) rotate(-90deg);
}
.mobile-menu-checkbox:checked + .burger-icon .burger-icon-menu {
opacity: 0;
transform: scale(0.8) rotate(90deg);
}
.mobile-menu-checkbox:checked + .burger-icon .burger-icon-close {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.mobile-dropdown {
display: grid;
grid-template-rows: 0fr;
width: 100%;
opacity: 0;
margin-top: 0;
padding-top: 0;
border-top: 1px solid transparent;
pointer-events: none;
transition:
grid-template-rows 0.35s ease,
opacity 0.35s ease,
margin-top 0.35s ease,
padding-top 0.35s ease,
border-color 0.35s ease;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown {
grid-template-rows: 1fr;
opacity: 1;
margin-top: 8px;
padding-top: 12px;
border-top-color: rgba(60, 60, 60, 0.3);
pointer-events: auto;
}
.mobile-dropdown-inner {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
min-height: 0;
}
.mobile-dropdown-item {
display: block;
width: 100%;
padding: 12px 10px;
border-radius: 16px;
text-align: left;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 0.3s ease,
transform 0.3s ease,
background-color 0.3s ease;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item {
opacity: 1;
transform: translateY(0);
}
.mobile-dropdown-item:hover {
background-color: rgba(60, 60, 60, 0.4);
transform: translateY(0);
}
/* Responsive design */
@media (max-width: 750px) {
.menubar {
min-width: 0;
width: calc(100% - 24px);
margin-top: 16px;
justify-content: flex-start;
}
.desktop-menu {
display: none;
}
.mobile-menu {
display: block;
}
.menubar-content {
justify-content: flex-start;
align-items: stretch;
}
.menubar:has(.mobile-menu-checkbox:checked) .menubar-content {
flex-direction: column;
align-items: stretch;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(1) {
transition-delay: 0.05s;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(2) {
transition-delay: 0.1s;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(3) {
transition-delay: 0.15s;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(4) {
transition-delay: 0.2s;
}
}
@media (prefers-reduced-motion: reduce) {
.menubar,
.menubar-content,
.burger-icon-menu,
.burger-icon-close,
.mobile-dropdown,
.mobile-dropdown-item {
transition: none;
}
.mobile-menu-checkbox:checked + .burger-icon .burger-icon-menu,
.burger-icon-close {
transform: none;
}
.mobile-dropdown-item {
opacity: 1;
transform: none;
}
}

167
components/contact-form.css Normal file
View File

@@ -0,0 +1,167 @@
.contact-form-box {
position: relative;
isolation: isolate;
width: 100%;
max-width: 36rem;
margin: 0;
padding: 2rem;
border-radius: 1.5rem;
background-color: rgba(255, 255, 255, 0.95);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.contact-form-box::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.45),
rgba(109, 40, 217, 0.35),
rgba(60, 60, 60, 0.15)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.contact-form-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.contact-form-title {
margin: 0 0 0.25rem;
font-size: 1.75rem;
font-weight: 700;
background: linear-gradient(90deg, #38bdf8, #6d28d9);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.contact-form-description {
margin: 0 0 0.5rem;
font-size: 0.95rem;
line-height: 1.5;
color: #6b7280;
}
.contact-form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.contact-form-label {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
.contact-form-label-optional {
font-weight: 400;
color: #9ca3af;
}
.contact-form-input,
.contact-form-textarea {
width: 100%;
border-radius: 0.75rem;
border: 1px solid rgba(60, 60, 60, 0.25);
background-color: rgba(255, 255, 255, 0.9);
padding: 0.75rem 1rem;
font-size: 1rem;
line-height: 1.5;
color: #171717;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.contact-form-input::placeholder,
.contact-form-textarea::placeholder {
color: #9ca3af;
}
.contact-form-input:focus,
.contact-form-textarea:focus {
outline: none;
border-color: #38bdf8;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
}
.contact-form-textarea {
min-height: 10rem;
resize: vertical;
}
.contact-form-submit {
align-self: flex-start;
margin-top: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.5rem;
border: none;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
color: #fff;
background: linear-gradient(90deg, #38bdf8, #6d28d9);
box-shadow: 0 2px 12px rgba(56, 189, 248, 0.25);
cursor: pointer;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
filter 0.25s ease;
}
.contact-form-submit:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(109, 40, 217, 0.35);
filter: brightness(1.08);
}
.contact-form-submit:focus-visible {
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
@media (prefers-color-scheme: dark) {
.contact-form-box {
background-color: rgba(24, 24, 27, 0.95);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.contact-form-description {
color: #a1a1aa;
}
.contact-form-label {
color: #e4e4e7;
}
.contact-form-input,
.contact-form-textarea {
border-color: rgba(255, 255, 255, 0.15);
background-color: rgba(0, 0, 0, 0.25);
color: #fafafa;
}
}

View File

@@ -0,0 +1,73 @@
'use client'
import { type FormEvent, useState } from 'react'
import './contact-form.css'
export default function ContactForm() {
const [submitted, setSubmitted] = useState(false)
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitted(true)
}
return (
<div className="contact-form-box">
<form className="contact-form-inner" onSubmit={handleSubmit} noValidate>
<div>
<h2 className="contact-form-title">Get in touch</h2>
<p className="contact-form-description">
Send us a message and we&apos;ll get back to you as soon as we can.
</p>
</div>
<div className="contact-form-field">
<label htmlFor="contact-email" className="contact-form-label">
Email
</label>
<input
id="contact-email"
name="email"
type="email"
required
autoComplete="email"
placeholder="you@example.com"
className="contact-form-input"
/>
</div>
<div className="contact-form-field">
<label htmlFor="contact-phone" className="contact-form-label">
Phone <span className="contact-form-label-optional">(optional)</span>
</label>
<input
id="contact-phone"
name="phone"
type="tel"
autoComplete="tel"
placeholder="+44 7700 900000"
className="contact-form-input"
/>
</div>
<div className="contact-form-field">
<label htmlFor="contact-message" className="contact-form-label">
Message
</label>
<textarea
id="contact-message"
name="message"
required
rows={6}
placeholder="Tell us about your project..."
className="contact-form-textarea"
/>
</div>
<button type="submit" className="contact-form-submit">
{submitted ? 'Message sent' : 'Send message'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,62 @@
/* Slide width is driven by CSS breakpoints (not JS innerWidth) so real phones match devtools */
.carousel-viewport {
width: 100%;
}
.carousel-slide {
flex: 0 0 100%;
min-width: 0;
box-sizing: border-box;
}
@media (min-width: 640px) {
.carousel-slide {
flex: 0 0 50%;
}
}
@media (min-width: 1024px) {
.carousel-slide {
flex: 0 0 33.333%;
}
}
.carousel-controls {
position: relative;
z-index: 20;
isolation: isolate;
}
.carousel-control-btn {
display: inline-flex;
min-height: 44px;
min-width: 44px;
align-items: center;
justify-content: center;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
border-radius: 9999px;
border: 1px solid var(--color-default-medium, rgba(60, 60, 60, 0.35));
background-color: var(--color-neutral-secondary-medium, rgba(60, 60, 60, 0.25));
padding: 0.5rem 1rem;
font-size: 1.125rem;
line-height: 1;
color: inherit;
transition: background-color 0.2s ease, color 0.2s ease;
}
.carousel-control-btn:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.9);
color: #000;
}
.carousel-control-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.carousel-control-btn:active:not(:disabled) {
transform: scale(0.96);
}

View File

@@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import ContentCard from './ui/content-cards'
import './content-card-carousel.css'
interface ContentCardItem {
src?: string
button_link?: string
heading?: string
button_text?: string
card_text?: string
}
interface ContentCardCarouselProps {
cards: ContentCardItem[]
}
// correctly works
function getVisibleCardCount() {
if (typeof window === 'undefined') return 1
if (window.matchMedia('(min-width: 1024px)').matches) return 3
if (window.matchMedia('(min-width: 640px)').matches) return 2
return 1
}
export default function ContentCardCarousel({ cards }: ContentCardCarouselProps) {
const [visibleCards, setVisibleCards] = useState(1)
const [currentIndex, setCurrentIndex] = useState(0)
const syncVisibleCards = useCallback(() => {
setVisibleCards(getVisibleCardCount())
}, [])
useEffect(() => {
syncVisibleCards()
const mediaQueries = [
window.matchMedia('(min-width: 640px)'),
window.matchMedia('(min-width: 1024px)'),
]
const onBreakpointChange = () => syncVisibleCards()
mediaQueries.forEach((mq) => mq.addEventListener('change', onBreakpointChange))
window.addEventListener('resize', onBreakpointChange)
return () => {
mediaQueries.forEach((mq) => mq.removeEventListener('change', onBreakpointChange))
window.removeEventListener('resize', onBreakpointChange)
}
}, [syncVisibleCards])
const maxIndex = useMemo(() => Math.max(cards.length - visibleCards, 0), [cards.length, visibleCards])
// custom mod function because JS does not have working modulus
function mod(n: number, m: number) {
return ((n % m) + m) % m;
}
useEffect(() => {
setCurrentIndex((previous) => Math.min(previous, maxIndex))
}, [maxIndex])
// % of track width — matches flex slide sizes (100% / 50% / 33.333% per breakpoint)
const trackOffsetPercent = cards.length > 0 ? ((currentIndex) * -100) / visibleCards : 0
const next = () => {
if (maxIndex === 0) return
setCurrentIndex(mod(Math.abs((currentIndex + 1)), (cards.length)))
}
const previous = () => {
if (maxIndex === 0) return
setCurrentIndex(mod((currentIndex - 1), (cards.length)))
}
return (
<div className="w-full max-w-full pb-30">
<h1 className="mb-4 flex justify-center text-5xl font-bold">Our Case Studies</h1>
<div className="carousel-viewport relative overflow-hidden">
<div
className="carousel-track flex items-stretch transition-transform duration-500 ease-in-out"
style={{ transform: `translate3d(${trackOffsetPercent}%, 0, 0)` }}
>
{cards.map((card, index) => {
const isEdgeCard =
visibleCards > 1 && (index === currentIndex || index === currentIndex + visibleCards - 1)
return (
<div
key={`${card.heading ?? 'card'}-${index}`}
className={`carousel-slide flex shrink-0 justify-center px-2 py-3 transition-opacity duration-500 ${
isEdgeCard ? 'opacity-75' : 'opacity-100'
}`}
>
<div className="flex h-full w-full max-w-sm">
<ContentCard {...card} />
</div>
</div>
)
})}
</div>
<div className="absolute inset-y-0 left-0 w-12 bg-linear-to-r from-white to-transparent dark:from-black" />
<div className="absolute inset-y-0 right-0 w-12 bg-linear-to-l from-white to-transparent dark:from-black" />
</div>
<div className="carousel-controls relative z-20 mt-6 flex justify-center gap-4">
<button
type="button"
onClick={previous}
className="carousel-control-btn"
aria-label="Show previous content cards"
>
&#8592;
</button>
<button
type="button"
onClick={next}
disabled={maxIndex === 0}
className="carousel-control-btn"
aria-label="Show next content cards"
>
&#8594;
</button>
</div>
</div>
)
}

22
components/footer.tsx Normal file
View File

@@ -0,0 +1,22 @@
import saws from "../assets/saws.png"
import SocialMediaLinks from "./social-media-links"
export default function Footer() {
return (
<footer className="w-full border-t border-neutral-300 bg-white dark:border-neutral-800 dark:bg-black">
<div className="mx-auto flex w-full max-w-7xl flex-col items-center justify-between gap-4 px-8 py-6 text-sm text-body md:flex-row">
<div className="flex items-center gap-3">
<img src={saws.src} alt="Sculpted Arc logo" className="h-12 w-auto" />
<p className="text-center md:text-left">copyright 2026 sculpted arc</p>
</div>
<div className="flex items-center gap-5">
<a href="/privacy-policy" className="hover:text-heading transition-colors">
Privacy Policy
</a>
<SocialMediaLinks linkClassName="hover:text-heading transition-colors" />
</div>
</div>
</footer>
)
}

View File

@@ -3,6 +3,7 @@
.hero-main { .hero-main {
width: 100%; width: 100%;
min-height: 400px; min-height: 400px;
padding-bottom: 200px;
} }
.hero-picture-stack { .hero-picture-stack {
@@ -38,19 +39,9 @@
} }
@media (max-width: 750px) { @media (max-width: 750px) {
.hero-picture-stack { .herp-main {
width: 100%; width: 100%;
min-height: 260px; min-height: 260px;
} }
.hero-picture {
width: 75%;
max-width: 240px;
}
.hero-right {
width: 100%;
padding-left: 0;
padding-top: 60px;
}
} }

View File

@@ -1,21 +1,24 @@
import ContentCard from "./ui/content-cards"; import ContentCard from "./ui/content-cards";
import './hero-section.css' import './hero-section.css'
import heroPictureOne from "../assets/hero-picture-1.png" import heroPictureOne from "../assets/mxj-files-planet-27066.gif"
import heroPictureTwo from "../assets/hero-picture-2.png"
import heroPictureThree from "../assets/hero-picture-3.png"
export default function HeroSection() { export default function HeroSection({description, buttonText, imageSrc }: {description: string; buttonText: string; imageSrc: string } ) {
return( return(
<> <>
<div className="hero-main flex flex-wrap"> <div className="flex flex-wrap-reverse justify-items-center ">
<div className="hero-picture-stack p-4"> <div className="p-4 w-full md:w-1/2 shrink-0">
<img className="hero-picture" src={heroPictureOne.src} alt="" /> <img className="w-full h-auto rounded-2xl object-cover" src={imageSrc} alt="" />
<img className="hero-picture hero-picture--tilt-20" src={heroPictureTwo.src} alt="" />
<img className="hero-picture hero-picture--tilt-40" src={heroPictureThree.src} alt="" />
</div> </div>
<div className="hero-right"> <div className="w-full md:w-1/2">
<h1 className="mb-4 text-5xl font-bold text-heading md:text-6xl lg:text-7xl"><span className="text-transparent bg-clip-text bg-gradient-to-r bg-violet-800 from-sky-400">Sculpted Arc</span><br/> Web Solutions</h1> <h1 className="mb-4 font-bold text-heading text-6xl sm:text-7xl"><span className="text-transparent bg-clip-text bg-gradient-to-r bg-violet-800 from-sky-400">Sculpted Arc</span><br/> Web Solutions</h1>
<p className="text-lg font-normal text-body lg:text-xl">Here at Sculpted Arc we are invested in creating great website and solving complex web problems Here at Sculpted Arc we are invested in creating great website and solving complex web problems</p> <hr className="w-1/4 mb-7 mt-8 border-gray-700 border-2 rounded-2xl"/>
<p className="text-lg font-normal text-body lg:text-xl">{description}</p>
<br/>
<a href="#" className="transition duration-150 flex w-55 justify-start rounded-3xl text-body bg-neutral-secondary-medium box-border border border-default-medium hover:bg-blue-200 hover:text-black hover:text-heading focus:ring-4 focus:ring-neutral-tertiary shadow-xs font-medium leading-5 rounded-base text-sm px-4 py-2.5 focus:outline-none mt-auto">
{buttonText}
<svg className="w-3 h-3 ms-1.5 rtl:rotate-180 -me-0.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"><path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 12H5m14 0-4 4m4-4-4-4"/></svg>
</a>
</div> </div>
</div> </div>
</> </>

View File

@@ -1,74 +0,0 @@
'use client';
import { useRef, type MouseEvent } from 'react';
import './Menubar.css';
const menuLinks = [
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about-us' },
{ label: 'Plans', href: '/plans' },
{ label: 'Contact', href: '#' },
];
export default function Menubar() {
const menuToggleRef = useRef<HTMLInputElement>(null);
const closeMobileMenu = () => {
if (menuToggleRef.current) {
menuToggleRef.current.checked = false;
}
};
const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>) => {
closeMobileMenu();
event.currentTarget.blur();
};
return (
<nav className="menubar">
<div className="menubar-content">
<div className="desktop-menu">
{menuLinks.map((link) => (
<a key={link.label} href={link.href} className="menu-item">
{link.label}
</a>
))}
</div>
<div className="mobile-menu">
<input
ref={menuToggleRef}
type="checkbox"
id="mobile-nav-toggle"
className="mobile-menu-checkbox"
/>
<label
htmlFor="mobile-nav-toggle"
className="burger-icon"
aria-controls="mobile-nav-menu"
>
<span className="burger-icon-menu" aria-hidden="true">
&#9776;
</span>
<span className="burger-icon-close" aria-hidden="true">
&times;
</span>
</label>
<div id="mobile-nav-menu" className="mobile-dropdown">
<div className="mobile-dropdown-inner">
{menuLinks.map((link) => (
<a
key={link.label}
href={link.href}
className="menu-item mobile-dropdown-item"
onClick={handleLinkClick}
>
{link.label}
</a>
))}
</div>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -1,26 +1,30 @@
import picOne from "../assets/hero-picture-1.png"; import picOne from "../assets/hero-picture-1.png";
export default function PicWithText(){
interface PicWithTextProps {
src?: string
title?: string
text?: string
reverse: boolean
}
export default function PicWithText(content: PicWithTextProps){
let flex_state = content.reverse ? "md:flex-row flex-col" : "md:flex-row-reverse flex-col-reverse";
return ( return (
<div className="flex flex-col md:flex-row items-center md:items-start gap-8 w-full mt-10"> <div className={`flex ${flex_state} items-center md:items-start gap-8 w-full mt-10 pb-30`}>
<div className="w-full md:w-1/2 shrink-0"> <div className="w-full md:w-1/2 shrink-0">
<img <img
className="w-full h-auto rounded-2xl object-cover" className="w-full h-auto rounded-2xl object-cover"
src={picOne.src} src={content.src}
alt="" alt=""
/> />
</div> </div>
<div className="w-full md:w-1/2"> <div className="w-full md:w-1/2">
<h1 className="mb-4 text-5xl font-bold">Title of this section</h1> <h1 className="mb-4 text-5xl font-bold">{content.title}</h1>
<p className="text-lg font-normal text-body lg:text-xl">This is the paragraph for this section. This is also just random filler text to see how things look. here are some extra words htat make no sence!</p> <p className="text-lg font-normal text-body lg:text-xl">{content.text}</p>
<div className="flex items-center w-full my-8">
<div className="flex-1 h-px bg-neutral-300 dark:bg-neutral-700" />
<div className="px-3">
<svg className="w-6 h-6 text-neutral-400 dark:text-neutral-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 11V8a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1Zm0 0v2a4 4 0 0 1-4 4H5m14-6V8a1 1 0 0 0-1-1h-3a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1Zm0 0v2a4 4 0 0 1-4 4h-1"/></svg>
</div>
<div className="flex-1 h-px bg-neutral-300 dark:bg-neutral-700" />
</div>
<p>This is more text, however, this is actually a quote, yes, a quote indeed!</p>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,152 @@
.services-feature-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
width: 100%;
list-style: none;
padding: 0;
margin: 0;
}
@media (min-width: 768px) {
.services-feature-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.services-feature-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.service-feature {
position: relative;
isolation: isolate;
height: 100%;
border-radius: 1.5rem;
background-color: rgba(255, 255, 255, 0.95);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
}
.service-feature::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.45),
rgba(109, 40, 217, 0.35),
rgba(60, 60, 60, 0.15)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
transition: opacity 0.25s ease;
}
.service-feature:hover {
transform: translateY(-2px);
box-shadow:
0 12px 32px rgba(56, 189, 248, 0.12),
0 8px 24px rgba(109, 40, 217, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.service-feature:hover::after {
opacity: 1;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.65),
rgba(109, 40, 217, 0.55),
rgba(60, 60, 60, 0.2)
);
}
.service-feature-body {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 1.5rem;
}
.service-feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
margin-bottom: 1rem;
border-radius: 0.75rem;
color: #6d28d9;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.15),
rgba(109, 40, 217, 0.15)
);
}
.service-feature-title {
margin-bottom: 0.75rem;
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
color: var(--foreground, #171717);
}
.service-feature-description {
flex: 1;
font-size: 1rem;
line-height: 1.6;
color: #52525b;
}
@media (prefers-color-scheme: dark) {
.service-feature {
background-color: rgba(24, 24, 27, 0.95);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.service-feature:hover {
box-shadow:
0 12px 32px rgba(56, 189, 248, 0.15),
0 8px 24px rgba(109, 40, 217, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.service-feature-title {
color: #ededed;
}
.service-feature-description {
color: #a1a1aa;
}
}
@media (prefers-reduced-motion: reduce) {
.service-feature,
.service-feature::after {
transition: none;
}
.service-feature:hover {
transform: none;
}
}

View File

@@ -0,0 +1,91 @@
import './services-feature.css';
import { ShoppingCart, Workflow, Server, CodeXml, Plug, Globe } from 'lucide-react';
export type Service = {
title: string;
icon: React.ReactNode;
description: string;
};
const services: Service[] = [
{
title: 'Company Automation',
icon: <Workflow />,
description:
'Streamline operations and boost efficiency with custom automation solutions tailored to your business needs.',
},
{
title: 'Web Development',
icon: <CodeXml />,
description:
'Fast, reliable sites built with modern frameworks—scalable architecture that grows with your company.',
},
{
title: 'E-commerce',
icon: <ShoppingCart />,
description:
'Online stores with smooth checkout, inventory management, and payment integrations that convert. Options inclide Shopify, WooCommerce, and custom solutions.',
},
{
title: 'Custom Plugins & Extensions',
icon: <Plug />,
description:
'Extend your websites functionality with custom plugins and extensions tailored to your specific needs.',
},
{
title: 'Hosting & Maintenance',
icon: <Server />,
description:
'Secure hosting, updates, emails, backups, and monitoring so your site stays online and up to date.',
},
{
title: 'APIs & Integrations',
icon: <Globe />,
description:
'Connect CRMs, booking systems, analytics, and third-party tools into one cohesive workflow.',
},
];
function ServiceFeatureCard({ title, icon, description }: Service) {
return (
<article className="service-feature">
<div className="service-feature-body">
<div className="service-feature-icon" aria-hidden="true">
{icon}
</div>
<h3 className="service-feature-title">{title}</h3>
<p className="service-feature-description">{description}</p>
</div>
</article>
);
}
type ServicesFeatureProps = {
heading?: string;
subheading?: string;
items?: Service[];
};
export default function ServicesFeature({
heading = 'What we offer',
subheading = 'End-to-end web solutions for small to medium-sized businesses ',
items = services,
}: ServicesFeatureProps) {
return (
<section className="services-feature w-full">
<header className="mb-10 max-w-2xl">
<h2 className="mb-3 text-4xl font-bold tracking-tight text-heading md:text-5xl">
{heading}
</h2>
<p className="text-lg text-body lg:text-xl">{subheading}</p>
</header>
<ul className="services-feature-grid">
{items.map((service) => (
<li key={service.title} className="list-none">
<ServiceFeatureCard {...service} />
</li>
))}
</ul>
</section>
);
}

463
components/site-header.css Normal file
View File

@@ -0,0 +1,463 @@
.site-header-wrapper {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
justify-content: center;
width: 100%;
padding: 16px 20px 0;
pointer-events: none;
}
.site-header {
position: relative;
pointer-events: auto;
display: flex;
justify-content: center;
width: calc(100% - 8px);
min-width: 0;
max-width: 1120px;
margin: 0 auto;
border-radius: 30px;
padding: 12px 20px;
background-color: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow:
0 4px 30px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
isolation: isolate;
transition:
padding 0.35s ease,
border-radius 0.35s ease,
box-shadow 0.35s ease,
backdrop-filter 0.35s ease,
background-color 0.35s ease;
}
.site-header::before {
content: '';
position: absolute;
inset: -5px;
background: black;
filter: blur(25px);
border-radius: 20px;
z-index: -1;
opacity: 0.12;
pointer-events: none;
}
.site-header::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.5),
rgba(91, 33, 182, 0.4),
rgba(114, 8, 156, 0.5)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
}
.site-header--scrolled {
padding: 8px 16px;
border-radius: 24px;
background-color: rgba(20, 18, 18, 0.92);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.site-header--scrolled::before {
opacity: 0.18;
}
.site-header-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 16px;
width: 100%;
}
.site-header-logo {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
text-decoration: none;
color: #e0e0e0;
transition: opacity 0.25s ease;
}
.site-header-logo:hover {
opacity: 0.9;
}
.site-header-logo-img {
height: 36px;
width: auto;
transition: height 0.35s ease;
}
.site-header--scrolled .site-header-logo-img {
height: 30px;
}
.site-header-logo-text {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.02em;
background: linear-gradient(90deg, #38bdf8, #6d28d9);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.site-header-nav {
display: flex;
flex: 1;
justify-content: center;
gap: 8px;
}
.menu-item {
color: #c8c8c8;
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 8px 14px;
border-radius: 9999px;
/* Only animate transform — color/background transitions flash when active class toggles */
transition: transform 0.25s ease;
}
.menu-item:hover {
color: #e0e0e0;
background-color: rgba(66, 116, 255, 0.12);
transform: translateY(-1px);
}
.menu-item.menu-item--active,
.menu-item.menu-item--active:hover {
background-color: rgba(56, 189, 248, 0.12);
background-image: linear-gradient(90deg, #38bdf8, #a78bfa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
transform: none;
transition: none;
}
.site-header-actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.site-header-social {
color: #c8c8c8;
gap: 12px;
}
.site-header-social-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 9999px;
transition:
color 0.25s ease,
background-color 0.25s ease,
transform 0.25s ease;
}
.site-header-social-link:hover {
color: #38bdf8;
background-color: rgba(56, 189, 248, 0.12);
transform: translateY(-1px);
}
.site-header-cta {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 8px 18px;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
color: #fff;
background: linear-gradient(90deg, #38bdf8, #6d28d9);
box-shadow: 0 2px 12px rgba(56, 189, 248, 0.25);
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
filter 0.25s ease;
}
.site-header-cta:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(109, 40, 217, 0.35);
filter: brightness(1.08);
}
.site-header-cta:focus-visible {
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
.site-header-cta--mobile {
margin-top: 8px;
width: 100%;
text-align: center;
}
.site-header-social--mobile {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(60, 60, 60, 0.35);
justify-content: center;
}
.mobile-menu {
display: none;
margin-left: auto;
position: relative;
}
.mobile-menu-checkbox {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.burger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
color: #e0e0e0;
font-size: 2em;
line-height: 1;
padding: 5px 10px;
position: relative;
z-index: 3;
touch-action: manipulation;
width: 1.4em;
height: 1.4em;
cursor: pointer;
border-radius: 12px;
transition: background-color 0.25s ease;
}
.burger-icon:hover {
background-color: rgba(66, 116, 255, 0.12);
}
.burger-icon-menu,
.burger-icon-close {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
}
.burger-icon-menu {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.burger-icon-close {
opacity: 0;
transform: scale(0.8) rotate(-90deg);
}
.mobile-menu-checkbox:checked + .burger-icon .burger-icon-menu {
opacity: 0;
transform: scale(0.8) rotate(90deg);
}
.mobile-menu-checkbox:checked + .burger-icon .burger-icon-close {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.mobile-dropdown {
display: grid;
grid-template-rows: 0fr;
position: absolute;
top: 100%;
left: 0;
right: 0;
width: 100%;
opacity: 0;
margin-top: 0;
padding-top: 0;
border-top: 1px solid transparent;
pointer-events: none;
transition:
grid-template-rows 0.35s ease,
opacity 0.35s ease,
margin-top 0.35s ease,
padding-top 0.35s ease,
border-color 0.35s ease;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown {
grid-template-rows: 1fr;
opacity: 1;
margin-top: 8px;
padding-top: 12px;
border-top-color: rgba(60, 60, 60, 0.3);
pointer-events: auto;
}
.mobile-dropdown-inner {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
min-height: 0;
}
.mobile-dropdown-item {
display: block;
width: 100%;
text-align: left;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 0.3s ease,
transform 0.3s ease,
background-color 0.3s ease;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item,
.mobile-menu-checkbox:checked ~ .mobile-dropdown .site-header-cta--mobile,
.mobile-menu-checkbox:checked ~ .mobile-dropdown .site-header-social--mobile {
opacity: 1;
transform: translateY(0);
}
.mobile-dropdown-item:hover {
background-color: rgba(60, 60, 60, 0.4);
transform: translateY(0);
}
@media (max-width: 900px) {
.site-header-logo-text {
display: none;
}
}
@media (max-width: 800px) {
.site-header-wrapper {
padding: 12px 12px 0;
}
.site-header {
width: 100%;
padding: 10px 14px;
}
.site-header--scrolled {
padding: 8px 12px;
}
.desktop-menu,
.desktop-actions {
display: none;
}
.mobile-menu {
display: block;
}
.site-header-content {
flex-wrap: wrap;
}
.site-header:has(.mobile-menu-checkbox:checked) .site-header-content {
align-items: stretch;
}
.site-header:has(.mobile-menu-checkbox:checked) .mobile-menu {
width: 100%;
order: 10;
}
.mobile-dropdown {
position: static;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(1) {
transition-delay: 0.05s;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(2) {
transition-delay: 0.1s;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(3) {
transition-delay: 0.15s;
}
.mobile-menu-checkbox:checked ~ .mobile-dropdown .mobile-dropdown-item:nth-child(4) {
transition-delay: 0.2s;
}
}
@media (prefers-reduced-motion: reduce) {
.site-header,
.site-header-logo-img,
.burger-icon-menu,
.burger-icon-close,
.mobile-dropdown,
.mobile-dropdown-item,
.menu-item,
.site-header-cta,
.site-header-social-link {
transition: none;
}
.mobile-menu-checkbox:checked + .burger-icon .burger-icon-menu,
.burger-icon-close {
transform: none;
}
.mobile-dropdown-item,
.site-header-cta--mobile,
.site-header-social--mobile {
opacity: 1;
transform: none;
}
}

147
components/site-header.tsx Normal file
View File

@@ -0,0 +1,147 @@
'use client'
import { useEffect, useRef, useState, type MouseEvent } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import saws from '../assets/saws.png'
import SocialMediaLinks from './social-media-links'
import './site-header.css'
const menuLinks = [
{ label: 'Home', href: '/' },
{ label: 'About', href: '/about-us' },
{ label: 'Services', href: '/services' },
{ label: 'Contact', href: '/contact' },
] as const
const ctaHref = '/contact'
function isActivePath(pathname: string, href: string) {
if (href === '/') return pathname === '/'
return pathname === href || pathname.startsWith(`${href}/`)
}
export default function SiteHeader() {
const pathname = usePathname()
const menuToggleRef = useRef<HTMLInputElement>(null)
const [scrolled, setScrolled] = useState(false)
const [pendingPath, setPendingPath] = useState<string | null>(null)
const activePath = pendingPath ?? pathname
useEffect(() => {
setPendingPath(null)
}, [pathname])
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
const closeMobileMenu = () => {
if (menuToggleRef.current) {
menuToggleRef.current.checked = false
}
}
const handleNavClick =
(href: string) => (event: MouseEvent<HTMLAnchorElement>) => {
setPendingPath(href)
closeMobileMenu()
event.currentTarget.blur()
}
return (
<header className="site-header-wrapper">
<nav
className={`site-header${scrolled ? ' site-header--scrolled' : ''}`}
aria-label="Main navigation"
>
<div className="site-header-content">
<Link href="/" className="site-header-logo" onClick={handleNavClick('/')}>
<img src={saws.src} alt="Sculpted Arc" className="site-header-logo-img" />
</Link>
<div className="site-header-nav desktop-menu">
{menuLinks.map((link) => {
const active = isActivePath(activePath, link.href)
return (
<Link
key={link.label}
href={link.href}
className={`menu-item${active ? ' menu-item--active' : ''}`}
aria-current={active ? 'page' : undefined}
onClick={handleNavClick(link.href)}
>
{link.label}
</Link>
)
})}
</div>
<div className="site-header-actions desktop-actions">
<SocialMediaLinks
className="site-header-social"
linkClassName="site-header-social-link"
/>
<Link href={ctaHref} className="site-header-cta">
Get a quote
</Link>
</div>
<div className="mobile-menu">
<input
ref={menuToggleRef}
type="checkbox"
id="mobile-nav-toggle"
className="mobile-menu-checkbox"
/>
<label
htmlFor="mobile-nav-toggle"
className="burger-icon"
aria-controls="mobile-nav-menu"
>
<span className="burger-icon-menu" aria-hidden="true">
&#9776;
</span>
<span className="burger-icon-close" aria-hidden="true">
&times;
</span>
</label>
<div id="mobile-nav-menu" className="mobile-dropdown">
<div className="mobile-dropdown-inner">
{menuLinks.map((link) => {
const active = isActivePath(activePath, link.href)
return (
<Link
key={link.label}
href={link.href}
className={`menu-item mobile-dropdown-item${active ? ' menu-item--active' : ''}`}
aria-current={active ? 'page' : undefined}
onClick={handleNavClick(link.href)}
>
{link.label}
</Link>
)
})}
<Link
href={ctaHref}
className="site-header-cta site-header-cta--mobile"
onClick={handleNavClick(ctaHref)}
>
Get a quote
</Link>
<SocialMediaLinks
className="site-header-social site-header-social--mobile"
linkClassName="site-header-social-link"
/>
</div>
</div>
</div>
</div>
</nav>
</header>
)
}

View File

@@ -0,0 +1,54 @@
const socialLinks = [
{
href: 'https://www.instagram.com',
label: 'Instagram',
icon: (
<path d="M7.75 2h8.5A5.75 5.75 0 0 1 22 7.75v8.5A5.75 5.75 0 0 1 16.25 22h-8.5A5.75 5.75 0 0 1 2 16.25v-8.5A5.75 5.75 0 0 1 7.75 2Zm0 1.5A4.25 4.25 0 0 0 3.5 7.75v8.5A4.25 4.25 0 0 0 7.75 20.5h8.5a4.25 4.25 0 0 0 4.25-4.25v-8.5a4.25 4.25 0 0 0-4.25-4.25h-8.5Zm9.5 1.75a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5ZM12 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
),
},
{
href: 'https://www.linkedin.com',
label: 'LinkedIn',
icon: (
<path d="M6.94 8.5A1.56 1.56 0 1 1 6.94 5.4a1.56 1.56 0 0 1 0 3.1ZM5.5 10h2.88v8.5H5.5V10Zm4.63 0H12.9v1.16h.04c.39-.74 1.35-1.52 2.79-1.52 2.99 0 3.54 1.97 3.54 4.53v4.33h-2.88V14.66c0-.91-.02-2.07-1.26-2.07-1.27 0-1.46.99-1.46 2.01v3.9h-2.88V10Z" />
),
},
{
href: 'https://x.com',
label: 'X',
icon: (
<path d="M18.9 2H22l-6.77 7.74L23 22h-6.1l-4.78-6.26L6.63 22H3.5l7.25-8.28L1 2h6.26l4.32 5.7L18.9 2Zm-1.07 18h1.69L6.34 3.9H4.53L17.83 20Z" />
),
},
] as const
interface SocialMediaLinksProps {
className?: string
iconClassName?: string
linkClassName?: string
}
export default function SocialMediaLinks({
className = '',
iconClassName = 'h-5 w-5',
linkClassName = 'transition-colors hover:text-[#4274ff]',
}: SocialMediaLinksProps) {
return (
<div className={`flex items-center gap-4 ${className}`.trim()}>
{socialLinks.map((link) => (
<a
key={link.label}
href={link.href}
className={linkClassName}
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
>
<svg className={iconClassName} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
{link.icon}
</svg>
</a>
))}
</div>
)
}

View File

@@ -0,0 +1,25 @@
.typing-words {
display: inline;
white-space: nowrap;
}
.typing-words-cursor {
display: inline-block;
margin-left: 0.05em;
font-weight: 300;
color: #38bdf8;
animation: typing-cursor-blink 1s step-end infinite;
}
@keyframes typing-cursor-blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.typing-words-cursor {
animation: none;
opacity: 0.6;
}
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import './typing-words.css';
const WORDS = ['plan', 'vision', 'problem', 'goals', 'business'] as const;
const TYPING_MS = 90;
const DELETING_MS = 55;
const PAUSE_AFTER_TYPED_MS = 2000;
const PAUSE_AFTER_DELETED_MS = 400;
type Phase = 'typing' | 'pausing' | 'deleting' | 'waiting';
export default function TypingWords() {
const [wordIndex, setWordIndex] = useState(0);
const [charIndex, setCharIndex] = useState(0);
const [phase, setPhase] = useState<Phase>('typing');
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(media.matches);
const onChange = () => setPrefersReducedMotion(media.matches);
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, []);
useEffect(() => {
if (prefersReducedMotion) return;
const currentWord = WORDS[wordIndex];
let timeoutId: ReturnType<typeof setTimeout>;
if (phase === 'typing') {
if (charIndex < currentWord.length) {
timeoutId = setTimeout(() => setCharIndex((i) => i + 1), TYPING_MS);
} else {
timeoutId = setTimeout(() => setPhase('pausing'), PAUSE_AFTER_TYPED_MS);
}
} else if (phase === 'pausing') {
timeoutId = setTimeout(() => setPhase('deleting'), 0);
} else if (phase === 'deleting') {
if (charIndex > 0) {
timeoutId = setTimeout(() => setCharIndex((i) => i - 1), DELETING_MS);
} else {
timeoutId = setTimeout(() => {
setWordIndex((i) => (i + 1) % WORDS.length);
setPhase('waiting');
}, PAUSE_AFTER_DELETED_MS);
}
} else if (phase === 'waiting') {
timeoutId = setTimeout(() => setPhase('typing'), 0);
}
return () => clearTimeout(timeoutId);
}, [charIndex, phase, wordIndex, prefersReducedMotion]);
useEffect(() => {
if (!prefersReducedMotion) return;
const intervalId = setInterval(() => {
setWordIndex((i) => (i + 1) % WORDS.length);
}, 3000);
return () => clearInterval(intervalId);
}, [prefersReducedMotion]);
const displayedWord = prefersReducedMotion
? WORDS[wordIndex]
: WORDS[wordIndex].slice(0, charIndex);
return (
<span className="typing-words" aria-live="polite">
<span className="typing-words-text text-transparent bg-clip-text bg-gradient-to-r from-sky-400 to-violet-800">
{displayedWord}
</span>
<span className="typing-words-cursor" aria-hidden="true">
|
</span>
</span>
);
}

View File

@@ -0,0 +1,137 @@
.content-card {
position: relative;
isolation: isolate;
border-radius: 1.5rem;
background-color: rgba(255, 255, 255, 0.95);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transition:
transform 0.25s ease,
box-shadow 0.25s ease;
}
.content-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.45),
rgba(109, 40, 217, 0.35),
rgba(60, 60, 60, 0.15)
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
transition: opacity 0.25s ease;
}
.content-card:hover {
transform: translateY(-2px);
box-shadow:
0 12px 32px rgba(56, 189, 248, 0.12),
0 8px 24px rgba(109, 40, 217, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.content-card:hover::after {
opacity: 1;
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.65),
rgba(109, 40, 217, 0.55),
rgba(60, 60, 60, 0.2)
);
}
.content-card-body {
position: relative;
z-index: 1;
}
.content-card-image {
overflow: hidden;
border-radius: 1rem;
background-color: rgba(0, 0, 0, 0.04);
}
.content-card-image img {
transition: transform 0.35s ease;
}
.content-card:hover .content-card-image img {
transform: scale(1.05);
}
.content-card-cta {
display: inline-flex;
align-items: center;
border-radius: 9999px;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25rem;
text-decoration: none;
color: #fff;
background: linear-gradient(90deg, #38bdf8, #6d28d9);
box-shadow: 0 2px 12px rgba(56, 189, 248, 0.25);
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
filter 0.25s ease;
}
.content-card-cta:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(109, 40, 217, 0.35);
filter: brightness(1.08);
}
.content-card-cta:focus-visible {
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
@media (prefers-color-scheme: dark) {
.content-card {
background-color: rgba(24, 24, 27, 0.95);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.content-card:hover {
box-shadow:
0 12px 32px rgba(56, 189, 248, 0.15),
0 8px 24px rgba(109, 40, 217, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.content-card-image {
background-color: rgba(255, 255, 255, 0.04);
}
}
@media (prefers-reduced-motion: reduce) {
.content-card,
.content-card::after,
.content-card-image img,
.content-card-cta {
transition: none;
}
.content-card:hover,
.content-card:hover .content-card-image img,
.content-card-cta:hover {
transform: none;
}
}

View File

@@ -1,3 +1,4 @@
import './content-cards.css'
interface cardProps { interface cardProps {
src?: string src?: string
@@ -9,20 +10,44 @@ interface cardProps {
export default function ContentCard(CardProps: cardProps) { export default function ContentCard(CardProps: cardProps) {
return ( return (
<> <article className="content-card group flex h-full w-full min-h-80 flex-col p-6">
<div className="bg-neutral-primary-soft shadow-lg shadow-blue-500/50 rounded-3xl block p-6 border-2 border-blue-500 rounded-base m-3 min-w-60 max-w-80"> <div className="content-card-body flex h-full flex-col">
<a href="#"> <a href="#" className="block shrink-0">
<img className="rounded-2xl" src={CardProps.src} alt="" /> <div className="content-card-image aspect-video w-full">
<img
className="h-full w-full object-cover"
src={CardProps.src}
alt=""
/>
</div>
</a> </a>
<a href="#"> <a href="#" className="mt-6 block shrink-0">
<h5 className="mt-6 mb-2 text-2xl font-semibold tracking-tight text-heading">{CardProps.heading}</h5> <h5 className="mb-2 text-2xl font-semibold tracking-tight text-heading">
{CardProps.heading}
</h5>
</a> </a>
<p className="mb-6 text-body">{CardProps.card_text}</p> <p className="mb-6 line-clamp-3 flex-1 text-body">{CardProps.card_text}</p>
<a href="#" className="inline-flex rounded-3xl items-center text-body bg-neutral-secondary-medium box-border border border-default-medium hover:bg-neutral-tertiary-medium hover:text-heading focus:ring-4 focus:ring-neutral-tertiary shadow-xs font-medium leading-5 rounded-base text-sm px-4 py-2.5 focus:outline-none"> <a href="#" className="content-card-cta mt-auto self-start">
{CardProps.button_text} {CardProps.button_text}
<svg className="w-3 h-3 ms-1.5 rtl:rotate-180 -me-0.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"><path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 12H5m14 0-4 4m4-4-4-4"/></svg> <svg
className="ms-1.5 -me-0.5 h-3 w-3 rtl:rotate-180"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 12H5m14 0-4 4m4-4-4-4"
/>
</svg>
</a> </a>
</div> </div>
</> </article>
) )
} }

32
compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
# Node.js service (use with: docker compose up nextjs-standalone --build)
nextjs-standalone:
build:
context: .
dockerfile: Dockerfile
image: nextjs-standalone-image
container_name: nextjs-standalone-frontend
environment:
NODE_ENV: production
PORT: "3000"
ports:
- "3000:3000"
restart: unless-stopped
# Bun service (use with: docker compose up nextjs-standalone-with-bun --build)
nextjs-standalone-with-bun:
build:
context: .
dockerfile: Dockerfile.bun
image: nextjs-standalone-bun-image
container_name: nextjs-standalone-bun-container
environment:
NODE_ENV: production
PORT: "3000"
ports:
- "3000:3000"
restart: unless-stopped
networks:
traefik_proxy:
external: true

View File

@@ -0,0 +1,5 @@
---
title: "Case Study One"
---
This is the fist case study

View File

@@ -0,0 +1 @@
Hello, this is case study two

19
deploy.yml.bak Normal file
View File

@@ -0,0 +1,19 @@
name: Build and Deploy NextJS SculptedArc Website
on: [push]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
# 1. Log into your self-hosted gitea container storage layer
- name: Log in to gitea Container Registry
uses: docker/login-action@v4
with:
registry: gitea.sculpted-arc.co.uk # Change to your gitea domain
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN_GITEA }} # Store a personal access token in your repo secrets for security

33
lib/case-study.ts Normal file
View File

@@ -0,0 +1,33 @@
// src/lib/blog.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
const postsDirectory = path.join(process.cwd(), 'content/case-studies');
export async function getPostBySlug(slug: string) {
const fullPath = path.join(postsDirectory, `${slug}.md`);
// Guard against file system traversal attacks or missing files
if (!fs.existsSync(fullPath)) {
return null;
}
const fileContents = fs.readFileSync(fullPath, 'utf8');
// Use gray-matter to parse the study metadata section
const { data, content } = matter(fileContents);
// Use remark to convert markdown into HTML string
const processedContent = await remark().use(html).process(content);
const contentHtml = processedContent.toString();
return {
slug,
contentHtml,
title: data.title || 'Untitled Study',
...data,
};
}

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;

View File

@@ -9,9 +9,14 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"gray-matter": "^4.0.3",
"html-react-parser": "^6.1.3",
"lucide-react": "^1.17.0",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"remark": "^15.0.1",
"remark-html": "^16.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

745
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
ignoredBuiltDependencies: allowBuilds:
sharp@0.34.5: true
unrs-resolver@1.11.1: true
onlyBuiltDependencies:
- sharp - sharp
- unrs-resolver - unrs-resolver