udpate for coming soon
This commit is contained in:
parent
243a182d33
commit
cdf83cd466
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
README.md
|
||||
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
COMPOSE_PROJECT_NAME=diyaa
|
||||
CONTAINER_NAME=diyaa
|
||||
APP_PORT=30002
|
||||
NODE_ENV=production
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
NEXT_PUBLIC_SITE_MODE=coming-soon
|
||||
|
||||
NEXT_PUBLIC_SITE_URL=https://your-domain.example
|
||||
NEXT_PUBLIC_CONTACT_EMAIL=
|
||||
NEXT_PUBLIC_LINKEDIN_URL=
|
||||
NEXT_PUBLIC_GITHUB_URL=
|
||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
@ -1,10 +1,12 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
@ -14,6 +16,7 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
53
README.md
Normal file
53
README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Diyaa Website
|
||||
|
||||
A bilingual Next.js website prepared for deployment on a private Linux VPS with Docker.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 14
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Docker / Docker Compose
|
||||
|
||||
## Local setup
|
||||
|
||||
1. Copy `.env.example` to `.env`
|
||||
2. Fill in `NEXT_PUBLIC_SITE_URL` and your real contact links
|
||||
3. Choose `NEXT_PUBLIC_SITE_MODE=coming-soon` or `NEXT_PUBLIC_SITE_MODE=full`
|
||||
4. Install dependencies with `npm install`
|
||||
5. Run `npm run dev`
|
||||
|
||||
## Quality checks
|
||||
|
||||
- `npm run lint`
|
||||
- `npm run typecheck`
|
||||
- `npm run build`
|
||||
- `npm run check`
|
||||
|
||||
## Docker workflow
|
||||
|
||||
- `make build`
|
||||
- `make start`
|
||||
- `make ps`
|
||||
- `make logs`
|
||||
- `make stop`
|
||||
|
||||
The container healthcheck uses `/api/health`.
|
||||
|
||||
## STRATO VPS deployment notes
|
||||
|
||||
1. Install Docker Engine and Docker Compose plugin on the VPS.
|
||||
2. Clone the repository on the server.
|
||||
3. Create `.env` from `.env.example` and set a real domain in `NEXT_PUBLIC_SITE_URL`.
|
||||
4. Run `make build` once, then `make start`.
|
||||
5. Place Nginx or Caddy in front of the container for HTTPS and domain routing.
|
||||
6. Point your domain to the VPS public IP and enable SSL at the reverse-proxy layer.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `APP_PORT`: external port exposed by Docker Compose
|
||||
- `NEXT_PUBLIC_SITE_URL`: canonical site URL used by metadata and sitemap
|
||||
- `NEXT_PUBLIC_SITE_MODE`: `coming-soon` or `full`
|
||||
- `NEXT_PUBLIC_CONTACT_EMAIL`: public email shown on the contact page
|
||||
- `NEXT_PUBLIC_LINKEDIN_URL`: LinkedIn profile URL
|
||||
- `NEXT_PUBLIC_GITHUB_URL`: GitHub profile URL
|
||||
@ -1,19 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||
import { notFound } from "next/navigation";
|
||||
import { buildPageMetadata } from "@/lib/metadata";
|
||||
import { isComingSoonMode } from "@/lib/site";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||
if (!isLocale(params.locale)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return buildPageMetadata(params.locale, "about");
|
||||
}
|
||||
|
||||
export default function AboutPage({ params }: { params: { locale: string } }) {
|
||||
if (!isLocale(params.locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (isComingSoonMode()) {
|
||||
redirect(`/${params.locale}`);
|
||||
}
|
||||
|
||||
const locale = params.locale as Locale;
|
||||
const dictionary = getDictionary(locale);
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<p className="eyebrow">{dictionary.about.kicker}</p>
|
||||
<h1>{dictionary.about.title}</h1>
|
||||
<p className="lead">{dictionary.about.story}</p>
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<p className="eyebrow">{dictionary.about.kicker}</p>
|
||||
<h1>{dictionary.about.title}</h1>
|
||||
<p className="lead">{dictionary.about.story}</p>
|
||||
</div>
|
||||
|
||||
<div className="split-grid">
|
||||
<article className="card">
|
||||
@ -34,6 +51,15 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="card">
|
||||
<h2>{dictionary.about.principlesTitle}</h2>
|
||||
<ul className="list">
|
||||
{dictionary.about.principles.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,28 +1,69 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||
import { notFound } from "next/navigation";
|
||||
import { buildPageMetadata } from "@/lib/metadata";
|
||||
import { getContactChannels, isComingSoonMode } from "@/lib/site";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||
if (!isLocale(params.locale)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return buildPageMetadata(params.locale, "contact");
|
||||
}
|
||||
|
||||
export default function ContactPage({ params }: { params: { locale: string } }) {
|
||||
if (!isLocale(params.locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (isComingSoonMode()) {
|
||||
redirect(`/${params.locale}`);
|
||||
}
|
||||
|
||||
const locale = params.locale as Locale;
|
||||
const dictionary = getDictionary(locale);
|
||||
const channels = getContactChannels(dictionary.contact);
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<p className="eyebrow">{dictionary.contact.kicker}</p>
|
||||
<h1>{dictionary.contact.title}</h1>
|
||||
<p className="lead">{dictionary.contact.description}</p>
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<p className="eyebrow">{dictionary.contact.kicker}</p>
|
||||
<h1>{dictionary.contact.title}</h1>
|
||||
<p className="lead">{dictionary.contact.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="split-grid">
|
||||
{dictionary.contact.channels.map((channel) => (
|
||||
<article className="card" key={channel.name}>
|
||||
<h2>{channel.name}</h2>
|
||||
<p>{channel.status}</p>
|
||||
<button type="button" className="pending-btn" disabled aria-disabled="true">
|
||||
{dictionary.contact.pendingCta}
|
||||
</button>
|
||||
<article className="card availability-card">
|
||||
<h2>{dictionary.contact.availabilityTitle}</h2>
|
||||
<p>{dictionary.contact.availabilityDescription}</p>
|
||||
</article>
|
||||
|
||||
<div className="section-heading">
|
||||
<h2>{dictionary.contact.channelsTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="split-grid contact-grid">
|
||||
{channels.map((channel) => (
|
||||
<article className="card contact-channel-card" key={channel.key}>
|
||||
<div className="channel-meta">
|
||||
<h3>{channel.name}</h3>
|
||||
<p>{channel.hint}</p>
|
||||
</div>
|
||||
<p className="contact-value">{channel.value}</p>
|
||||
{channel.href ? (
|
||||
<a
|
||||
href={channel.href}
|
||||
className="card-link"
|
||||
target={channel.external ? "_blank" : undefined}
|
||||
rel={channel.external ? "noreferrer" : undefined}
|
||||
>
|
||||
{dictionary.contact.channelCta}
|
||||
</a>
|
||||
) : (
|
||||
<span className="card-link is-disabled" aria-disabled="true">
|
||||
{dictionary.contact.channelFallback}
|
||||
</span>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,8 @@ import SiteFooter from "@/components/SiteFooter";
|
||||
import SiteHeader from "@/components/SiteHeader";
|
||||
import { getDictionary, getDirection, isLocale, locales, type Locale } from "@/lib/i18n";
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||
import { buildPageMetadata } from "@/lib/metadata";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||
if (!isLocale(params.locale)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return buildPageMetadata(params.locale, "home");
|
||||
}
|
||||
|
||||
export default function HomePage({ params }: { params: { locale: string } }) {
|
||||
if (!isLocale(params.locale)) {
|
||||
notFound();
|
||||
|
||||
16
app/api/health/route.ts
Normal file
16
app/api/health/route.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
154
app/globals.css
154
app/globals.css
@ -61,6 +61,7 @@ body {
|
||||
var(--bg);
|
||||
font-family: "Space Grotesk", "IBM Plex Sans Arabic", "Cairo", sans-serif;
|
||||
line-height: 1.6;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a {
|
||||
@ -96,12 +97,55 @@ a {
|
||||
padding: 0.9rem 0;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--surface-2) 72%, transparent);
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 180ms ease, color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.header-nav a:hover,
|
||||
.header-nav a:focus-visible {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--surface-3) 86%, transparent);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
||||
@ -164,6 +208,24 @@ a {
|
||||
padding: clamp(1.3rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
.section-stack {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.availability-badge {
|
||||
margin: 0 0 0.7rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--brand-2) 28%, var(--line));
|
||||
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.panel h1 {
|
||||
font-size: clamp(1.8rem, 5vw, 3.3rem);
|
||||
@ -194,8 +256,7 @@ a {
|
||||
}
|
||||
|
||||
.cta-btn,
|
||||
.ghost-btn,
|
||||
.pending-btn {
|
||||
.ghost-btn {
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.74rem 1rem;
|
||||
@ -213,12 +274,10 @@ a {
|
||||
background: var(--button-secondary);
|
||||
}
|
||||
|
||||
.pending-btn {
|
||||
margin-top: 0.6rem;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
background: var(--button-disabled);
|
||||
.cta-btn.is-disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cta-btn:hover,
|
||||
@ -279,6 +338,61 @@ a {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.availability-card h2,
|
||||
.contact-channel-card h3 {
|
||||
margin: 0 0 0.45rem;
|
||||
}
|
||||
|
||||
.contact-grid {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.contact-channel-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.channel-meta {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.contact-value {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.74rem 1rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: linear-gradient(115deg, var(--brand), var(--brand-2));
|
||||
transition: transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.card-link:hover,
|
||||
.card-link:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card-link.is-disabled {
|
||||
color: var(--muted);
|
||||
background: var(--button-disabled);
|
||||
border-color: var(--line);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-inline-start: 1.1rem;
|
||||
@ -300,6 +414,15 @@ a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.footer-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.locale-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -379,9 +502,10 @@ a {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
margin-inline-start: 0.18rem;
|
||||
border-color: var(--line) !important;
|
||||
@media (min-width: 861px) {
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
@ -396,6 +520,16 @@ a {
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.bar {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.split-grid,
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@ -1,14 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "diyaa | Personal Website",
|
||||
description: "Bilingual personal website built with Next.js",
|
||||
metadataBase: new URL(siteConfig.siteUrl),
|
||||
title: {
|
||||
default: "Diyaa",
|
||||
template: "%s | Diyaa",
|
||||
},
|
||||
description: "Bilingual professional website built for private-server deployment.",
|
||||
applicationName: "Diyaa",
|
||||
authors: [{ name: "Diyaa" }],
|
||||
creator: "Diyaa",
|
||||
publisher: "Diyaa",
|
||||
alternates: {
|
||||
languages: {
|
||||
ar: "/ar",
|
||||
en: "/en",
|
||||
"x-default": "/ar",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const themeScript = `
|
||||
(() => {
|
||||
const locale = window.location.pathname.split("/").filter(Boolean)[0] === "en" ? "en" : "ar";
|
||||
const direction = locale === "ar" ? "rtl" : "ltr";
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = direction;
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("theme");
|
||||
const activeTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
|
||||
@ -21,7 +41,7 @@ const themeScript = `
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="ar" data-theme="dark" suppressHydrationWarning>
|
||||
<html lang="ar" dir="rtl" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
|
||||
26
app/not-found.tsx
Normal file
26
app/not-found.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="page-content">
|
||||
<section className="panel section-stack">
|
||||
<div>
|
||||
<p className="eyebrow">404</p>
|
||||
<h1>الصفحة غير موجودة</h1>
|
||||
<p className="lead">
|
||||
الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="cta-row">
|
||||
<Link href="/ar" className="cta-btn">
|
||||
العودة إلى العربية
|
||||
</Link>
|
||||
<Link href="/en" className="ghost-btn">
|
||||
Go to English
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
12
app/robots.ts
Normal file
12
app/robots.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: `${siteConfig.siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
26
app/sitemap.ts
Normal file
26
app/sitemap.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getLocalizedPath, siteConfig } from "@/lib/site";
|
||||
|
||||
const pages = ["", "/about", "/contact"] as const;
|
||||
const locales = ["ar", "en"] as const;
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const lastModified = new Date();
|
||||
|
||||
return [
|
||||
{
|
||||
url: siteConfig.siteUrl,
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
},
|
||||
...pages.flatMap((page) =>
|
||||
locales.map((locale) => ({
|
||||
url: `${siteConfig.siteUrl}${getLocalizedPath(page, locale)}`,
|
||||
lastModified,
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: page === "" ? 1 : 0.7,
|
||||
})),
|
||||
),
|
||||
];
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { CommonContent } from "@/content/types";
|
||||
import { locales, type Locale } from "@/lib/i18n";
|
||||
import { type Locale } from "@/lib/i18n";
|
||||
import { isComingSoonMode } from "@/lib/site";
|
||||
|
||||
type BottomNavProps = {
|
||||
locale: Locale;
|
||||
@ -11,67 +11,49 @@ type BottomNavProps = {
|
||||
};
|
||||
|
||||
export default function BottomNav({ locale, common }: BottomNavProps) {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (isComingSoonMode()) {
|
||||
return (
|
||||
<nav aria-label={common.navLabel} className="bottom-nav">
|
||||
<div className="container bottom-nav-inner">
|
||||
<div className="bottom-nav-links">
|
||||
<Link href={`/${locale}`} className="active" aria-current="page">
|
||||
<span aria-hidden className="nav-icon">
|
||||
○
|
||||
</span>
|
||||
{common.nav.home}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedPath = pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||
const homePath = `/${locale}`;
|
||||
const aboutPath = `/${locale}/about`;
|
||||
const contactPath = `/${locale}/contact`;
|
||||
|
||||
const isHome = normalizedPath === homePath;
|
||||
const isAbout = normalizedPath === aboutPath || normalizedPath.startsWith(`${aboutPath}/`);
|
||||
const isContact = normalizedPath === contactPath || normalizedPath.startsWith(`${contactPath}/`);
|
||||
const targetLocale: Locale = locale === "ar" ? "en" : "ar";
|
||||
|
||||
const switchPath = () => {
|
||||
const nextSegments = [...segments];
|
||||
|
||||
if (nextSegments.length === 0) {
|
||||
return `/${targetLocale}`;
|
||||
}
|
||||
|
||||
if (locales.includes(nextSegments[0] as Locale)) {
|
||||
nextSegments[0] = targetLocale;
|
||||
} else {
|
||||
nextSegments.unshift(targetLocale);
|
||||
}
|
||||
|
||||
return `/${nextSegments.join("/")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<nav aria-label={common.navLabel} className="bottom-nav">
|
||||
<div className="container bottom-nav-inner">
|
||||
<div className="bottom-nav-links">
|
||||
<Link href={homePath} className={isHome ? "active" : undefined} aria-current={isHome ? "page" : undefined}>
|
||||
<Link href={homePath} className="active" aria-current="page">
|
||||
<span aria-hidden className="nav-icon">
|
||||
○
|
||||
</span>
|
||||
{common.nav.home}
|
||||
</Link>
|
||||
<Link href={aboutPath} className={isAbout ? "active" : undefined} aria-current={isAbout ? "page" : undefined}>
|
||||
<Link href={aboutPath}>
|
||||
<span aria-hidden className="nav-icon">
|
||||
□
|
||||
</span>
|
||||
{common.nav.about}
|
||||
</Link>
|
||||
<Link
|
||||
href={contactPath}
|
||||
className={isContact ? "active" : undefined}
|
||||
aria-current={isContact ? "page" : undefined}
|
||||
>
|
||||
<Link href={contactPath}>
|
||||
<span aria-hidden className="nav-icon">
|
||||
◇
|
||||
</span>
|
||||
{common.nav.contact}
|
||||
</Link>
|
||||
<Link href={switchPath()} className="lang-toggle" aria-label={common.languageSwitcherLabel}>
|
||||
<span aria-hidden className="nav-icon">
|
||||
⇄
|
||||
</span>
|
||||
{targetLocale.toUpperCase()}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import type { CommonContent, HomeContent } from "@/content/types";
|
||||
import type { CommonContent, HomeContent, HomeVariantContent } from "@/content/types";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { getEmailHref, isComingSoonMode } from "@/lib/site";
|
||||
|
||||
type HeroSectionProps = {
|
||||
locale: Locale;
|
||||
@ -9,23 +10,33 @@ type HeroSectionProps = {
|
||||
};
|
||||
|
||||
export default function HeroSection({ locale, home, common }: HeroSectionProps) {
|
||||
const activeHome: HomeVariantContent = isComingSoonMode() ? home.comingSoon : home.full;
|
||||
const emailHref = getEmailHref();
|
||||
|
||||
return (
|
||||
<section className="hero panel">
|
||||
<p className="eyebrow">{home.kicker}</p>
|
||||
<h1>{home.title}</h1>
|
||||
<p className="lead">{home.description}</p>
|
||||
<p className="availability-badge">{activeHome.badge}</p>
|
||||
<p className="eyebrow">{activeHome.kicker}</p>
|
||||
<h1>{activeHome.title}</h1>
|
||||
<p className="lead">{activeHome.description}</p>
|
||||
|
||||
<div className="cta-row">
|
||||
<Link href={`/${locale}/contact`} className="cta-btn">
|
||||
{home.primaryCta}
|
||||
</Link>
|
||||
<Link href={`/${locale}/about`} className="ghost-btn">
|
||||
{common.nav.about}
|
||||
{emailHref ? (
|
||||
<a href={emailHref} className="cta-btn">
|
||||
{activeHome.primaryCta}
|
||||
</a>
|
||||
) : (
|
||||
<span className="cta-btn is-disabled" aria-disabled="true">
|
||||
{activeHome.primaryCta}
|
||||
</span>
|
||||
)}
|
||||
<Link href={isComingSoonMode() ? `/${locale}` : `/${locale}/about`} className="ghost-btn">
|
||||
{activeHome.secondaryCta}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
{home.highlights.map((item) => (
|
||||
{activeHome.highlights.map((item) => (
|
||||
<article key={item.label} className="stat-card">
|
||||
<span className="stat-value">{item.value}</span>
|
||||
<span className="stat-label">{item.label}</span>
|
||||
|
||||
@ -30,7 +30,7 @@ export default function LanguageSwitcher({ locale, label }: LanguageSwitcherProp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="language-switcher" aria-label={label}>
|
||||
<div className="language-switcher" role="group" aria-label={label}>
|
||||
{locales.map((item) => {
|
||||
const active = item === locale;
|
||||
|
||||
|
||||
@ -12,7 +12,10 @@ export default function SiteFooter({ locale, common }: SiteFooterProps) {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="container footer-content">
|
||||
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
||||
<div className="footer-copy">
|
||||
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
||||
<p>{common.footerBuiltWith}</p>
|
||||
</div>
|
||||
<p className="locale-badge">{locale.toUpperCase()}</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import type { CommonContent } from "@/content/types";
|
||||
import { isComingSoonMode } from "@/lib/site";
|
||||
|
||||
type SiteHeaderProps = {
|
||||
locale: Locale;
|
||||
@ -9,13 +11,34 @@ type SiteHeaderProps = {
|
||||
};
|
||||
|
||||
export default function SiteHeader({ locale, common }: SiteHeaderProps) {
|
||||
const isComingSoon = isComingSoonMode();
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="container bar">
|
||||
<Link href={`/${locale}`} className="brand">
|
||||
{common.siteTitle}
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<div className="brand-block">
|
||||
<Link href={`/${locale}`} className="brand">
|
||||
{common.siteTitle}
|
||||
</Link>
|
||||
<span className="brand-tagline">{common.siteTagline}</span>
|
||||
</div>
|
||||
|
||||
{!isComingSoon ? (
|
||||
<nav className="header-nav" aria-label={common.navLabel}>
|
||||
<Link href={`/${locale}`}>{common.nav.home}</Link>
|
||||
<Link href={`/${locale}/about`}>{common.nav.about}</Link>
|
||||
<Link href={`/${locale}/contact`}>{common.nav.contact}</Link>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="header-actions">
|
||||
<LanguageSwitcher locale={locale} label={common.languageSwitcherLabel} />
|
||||
<ThemeToggle
|
||||
label={common.themeToggleLabel}
|
||||
lightLabel={common.themeLight}
|
||||
darkLabel={common.themeDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@ -6,11 +6,17 @@ type Theme = "dark" | "light";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
|
||||
type ThemeToggleProps = {
|
||||
label: string;
|
||||
lightLabel: string;
|
||||
darkLabel: string;
|
||||
};
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
export default function ThemeToggle({ label, lightLabel, darkLabel }: ThemeToggleProps) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
@ -34,9 +40,9 @@ export default function ThemeToggle() {
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="theme-toggle"
|
||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
aria-label={`${label}: ${theme === "dark" ? lightLabel : darkLabel}`}
|
||||
>
|
||||
<span className="theme-toggle-label">{theme === "dark" ? "Light" : "Dark"}</span>
|
||||
<span className="theme-toggle-label">{theme === "dark" ? lightLabel : darkLabel}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,52 +3,106 @@ import type { Dictionary } from "@/content/types";
|
||||
const ar: Dictionary = {
|
||||
common: {
|
||||
siteTitle: "ضياء",
|
||||
siteTagline: "موقع شخصي جديد قيد الإطلاق",
|
||||
navLabel: "التنقل الرئيسي",
|
||||
languageSwitcherLabel: "تبديل اللغة",
|
||||
themeToggleLabel: "نمط الواجهة",
|
||||
themeLight: "فاتح",
|
||||
themeDark: "داكن",
|
||||
nav: {
|
||||
home: "الرئيسية",
|
||||
about: "من أنا",
|
||||
contact: "تواصل",
|
||||
},
|
||||
footerRights: "{year} جميع الحقوق محفوظة",
|
||||
footerBuiltWith: "موقع ثنائي اللغة مهيأ للعرض المهني والنشر على خادم خاص.",
|
||||
availabilityBadge: "متاح لمشاريع الويب والتطوير المخصص",
|
||||
},
|
||||
home: {
|
||||
kicker: "Full-Stack Developer",
|
||||
title: "أبني أنظمة ويب حديثة وقابلة للتوسع",
|
||||
description:
|
||||
"مطور Full-Stack أعمل على بناء تطبيقات ويب حديثة باستخدام Next.js و NestJS و PostgreSQL. أركز على الأنظمة القابلة للتوسع وتجارب المستخدم السريعة.",
|
||||
primaryCta: "تواصل معي",
|
||||
highlights: [
|
||||
{ value: "+10", label: "مشاريع برمجية" },
|
||||
{ value: "Next.js / NestJS", label: "Stack الأساسي" },
|
||||
{ value: "Docker", label: "Dev Infrastructure" },
|
||||
],
|
||||
comingSoon: {
|
||||
badge: "Coming Soon",
|
||||
kicker: "قريبًا",
|
||||
title: "موقعي الجديد قيد التجهيز",
|
||||
description:
|
||||
"أعمل حاليًا على إطلاق نسخة جديدة بهوية أوضح وتجربة أسرع ومحتوى أكثر اكتمالًا. الصفحة الكاملة ستتوفر قريبًا بعد إنهاء اللمسات الأخيرة.",
|
||||
primaryCta: "تواصل عند الإطلاق",
|
||||
secondaryCta: "تابع قريبًا",
|
||||
highlights: [
|
||||
{ value: "نسخة جديدة", label: "واجهة ومحتوى محدثان" },
|
||||
{ value: "ثنائي اللغة", label: "عربي وإنجليزي" },
|
||||
{ value: "قريبًا", label: "الإطلاق الرسمي" },
|
||||
],
|
||||
},
|
||||
full: {
|
||||
badge: "متاح لمشاريع الويب والتطوير المخصص",
|
||||
kicker: "مطور ويب متكامل",
|
||||
title: "أبني مواقع وأنظمة ويب جاهزة للنمو",
|
||||
description:
|
||||
"أصمم وأطوّر واجهات وتجارب رقمية متماسكة، وأربطها ببنية تشغيل واضحة تجعل الإطلاق والصيانة والتوسع أسهل على المدى الطويل.",
|
||||
primaryCta: "ابدأ التواصل",
|
||||
secondaryCta: "اعرف المزيد",
|
||||
highlights: [
|
||||
{ value: "+10", label: "نماذج ومشاريع مطورة" },
|
||||
{ value: "ثنائي اللغة", label: "عربي وإنجليزي" },
|
||||
{ value: "جاهز للنشر", label: "Docker و VPS" },
|
||||
],
|
||||
},
|
||||
},
|
||||
about: {
|
||||
kicker: "نبذة سريعة",
|
||||
title: "من أنا",
|
||||
story:
|
||||
"أنا مطور Full-Stack أركز على بناء تطبيقات ويب حديثة وقابلة للتوسع. أعمل بشكل أساسي باستخدام Next.js و NestJS و PostgreSQL مع بنية تشغيل تعتمد على Docker. أركز في مشاريعي على الأداء، البنية النظيفة، وتجربة المستخدم السريعة، مع تصميم أنظمة يمكن تطويرها بسهولة مع نمو المشروع.",
|
||||
"أعمل على تحويل الأفكار إلى مواقع ومنتجات ويب واضحة وسريعة ومرنة. تركيزي الأساسي يكون على الجمع بين تجربة استخدام مرتبة، كود منظم، وتجهيز فعلي للنشر حتى لا يبقى المشروع جميلًا فقط بل قابلًا للاعتماد والتطوير لاحقًا.",
|
||||
skillsTitle: "مهارات",
|
||||
skills: ["تطوير واجهات", "تحسين تجربة المستخدم", "تنظيم المحتوى", "إدارة مشاريع رقمية"],
|
||||
skills: [
|
||||
"Next.js و React",
|
||||
"TypeScript وتنظيم الواجهات",
|
||||
"تصميم واجهات مرنة وسريعة",
|
||||
"تهيئة النشر على الخوادم الخاصة",
|
||||
"تحسين الأداء وتجربة الاستخدام",
|
||||
"تنظيم المحتوى متعدد اللغات",
|
||||
],
|
||||
experienceTitle: "خبرات مختصرة",
|
||||
experience: [
|
||||
"HTML",
|
||||
"CSS , SCSS",
|
||||
"React",
|
||||
"بناء مواقع تعريفية وصفحات هبوط وهوية رقمية مهنية",
|
||||
"تطوير واجهات تطبيقات حديثة قابلة للتوسع",
|
||||
"ربط الواجهة بتدفق نشر واضح باستخدام Docker",
|
||||
],
|
||||
principlesTitle: "منهج العمل",
|
||||
principles: [
|
||||
"بنية واضحة تسهّل التوسع والصيانة لاحقًا",
|
||||
"اهتمام مبكر بالأداء ووضوح الرسالة داخل الواجهة",
|
||||
"تجهيز عملي للنشر، الصحة التشغيلية، والاعتماد على ملفات البيئة",
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
kicker: "لنبدأ خطوة جديدة",
|
||||
title: "تواصل",
|
||||
description: "روابط التواصل ستتم إضافتها لاحقًا. كل العناصر أدناه جاهزة لاستقبال الروابط الفعلية.",
|
||||
pendingCta: "الرابط قيد الإضافة",
|
||||
kicker: "لنبدأ مشروعًا واضحًا وقابلًا للتنفيذ",
|
||||
title: "قنوات التواصل",
|
||||
description: "بمجرد إضافة روابطك الحقيقية في ملف البيئة ستظهر هنا تلقائيًا وتصبح جاهزة للاستخدام المباشر من الزوار.",
|
||||
availabilityTitle: "جاهزية الإطلاق",
|
||||
availabilityDescription:
|
||||
"النسخة الحالية مهيأة للنشر على خادم خاص مع فحص صحة داخلي وروابط تواصل قابلة للإدارة من الإعدادات.",
|
||||
channelsTitle: "روابطك المباشرة",
|
||||
channelCta: "فتح الرابط",
|
||||
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
|
||||
channels: [
|
||||
{ name: "Email", status: "سيتم إضافة الرابط قريبًا" },
|
||||
{ name: "LinkedIn", status: "سيتم إضافة الرابط قريبًا" },
|
||||
{ name: "GitHub", status: "سيتم إضافة الرابط قريبًا" },
|
||||
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
|
||||
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
|
||||
{ key: "github", name: "جيت هب", hint: "لاستعراض المشاريع والمستودعات البرمجية" },
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
homeTitle: "قريبًا",
|
||||
homeDescription: "صفحة ترحيبية مؤقتة للإعلان عن قرب إطلاق الموقع الشخصي الجديد.",
|
||||
fullHomeTitle: "الصفحة الرئيسية",
|
||||
fullHomeDescription: "موقع شخصي احترافي ثنائي اللغة يعرض الهوية المهنية والخبرة التقنية والاستعداد للنشر.",
|
||||
aboutTitle: "من أنا",
|
||||
aboutDescription: "تعرف على الخبرات، المهارات، ومنهج العمل في بناء مواقع وأنظمة ويب حديثة.",
|
||||
contactTitle: "تواصل",
|
||||
contactDescription: "صفحة تواصل جاهزة لعرض البريد وروابط المنصات المهنية مباشرة من الإعدادات.",
|
||||
notFoundTitle: "الصفحة غير موجودة",
|
||||
notFoundDescription: "تعذر العثور على الصفحة المطلوبة. يمكنك العودة إلى الصفحة الرئيسية أو التبديل بين اللغتين.",
|
||||
},
|
||||
};
|
||||
|
||||
export default ar;
|
||||
|
||||
102
content/en.ts
102
content/en.ts
@ -2,53 +2,107 @@ import type { Dictionary } from "@/content/types";
|
||||
|
||||
const en: Dictionary = {
|
||||
common: {
|
||||
siteTitle: "diyaa | Personal Site",
|
||||
siteTitle: "Diyaa",
|
||||
siteTagline: "A new personal site is on the way",
|
||||
navLabel: "Main navigation",
|
||||
languageSwitcherLabel: "Switch language",
|
||||
themeToggleLabel: "Interface theme",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
nav: {
|
||||
home: "Home",
|
||||
about: "About Me",
|
||||
about: "About",
|
||||
contact: "Contact",
|
||||
},
|
||||
footerRights: "{year} All rights reserved",
|
||||
footerBuiltWith: "A bilingual professional site prepared for deployment on a private server.",
|
||||
availabilityBadge: "Available for web builds and custom development",
|
||||
},
|
||||
home: {
|
||||
kicker: "Fresh personal presence",
|
||||
title: "A bold digital space for my professional identity",
|
||||
description:
|
||||
"This is placeholder copy you can replace quickly. It is built to highlight your profile, communicate value, and stay clean on every screen size.",
|
||||
primaryCta: "Start a conversation",
|
||||
highlights: [
|
||||
{ value: "3", label: "Core pages" },
|
||||
{ value: "RTL/LTR", label: "Direction ready" },
|
||||
{ value: "100%", label: "Customizable" },
|
||||
],
|
||||
comingSoon: {
|
||||
badge: "Coming Soon",
|
||||
kicker: "Coming soon",
|
||||
title: "A fresh version of this site is being prepared",
|
||||
description:
|
||||
"The full website is currently being refined with a stronger visual identity, sharper content, and a cleaner launch-ready experience. It will be live soon.",
|
||||
primaryCta: "Reach out when it launches",
|
||||
secondaryCta: "Stay tuned",
|
||||
highlights: [
|
||||
{ value: "New release", label: "Updated look and content" },
|
||||
{ value: "Bilingual", label: "Arabic and English ready" },
|
||||
{ value: "Soon", label: "Official launch" },
|
||||
],
|
||||
},
|
||||
full: {
|
||||
badge: "Available for web builds and custom development",
|
||||
kicker: "Full-stack web developer",
|
||||
title: "I build websites and web systems ready to grow",
|
||||
description:
|
||||
"I design and ship cohesive digital experiences backed by clean implementation and deployment-ready structure, so launch and maintenance stay manageable over time.",
|
||||
primaryCta: "Start a conversation",
|
||||
secondaryCta: "Learn more",
|
||||
highlights: [
|
||||
{ value: "10+", label: "Projects and product builds" },
|
||||
{ value: "Bilingual", label: "Arabic and English ready" },
|
||||
{ value: "Deployable", label: "Docker and VPS workflow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
about: {
|
||||
kicker: "Short introduction",
|
||||
title: "About me",
|
||||
story:
|
||||
"This is temporary biography text. Replace it with your real story, strengths, and the kind of outcomes you deliver through your work.",
|
||||
"My focus is turning ideas into web products that feel polished in the browser and dependable in production. I care about clean interfaces, maintainable structure, and deployment choices that make future growth easier instead of harder.",
|
||||
skillsTitle: "Skills",
|
||||
skills: ["Frontend engineering", "UX refinement", "Content structuring", "Digital project delivery"],
|
||||
skills: [
|
||||
"Next.js and React",
|
||||
"TypeScript-driven UI structure",
|
||||
"Fast, adaptable frontend systems",
|
||||
"Private-server deployment setup",
|
||||
"Performance and UX refinement",
|
||||
"Bilingual content architecture",
|
||||
],
|
||||
experienceTitle: "Experience highlights",
|
||||
experience: [
|
||||
"Placeholder experience line 1",
|
||||
"Placeholder experience line 2",
|
||||
"Placeholder experience line 3",
|
||||
"Professional landing pages and personal web presence builds",
|
||||
"Scalable frontend interfaces for modern web apps",
|
||||
"Docker-based delivery flows for cleaner deployment",
|
||||
],
|
||||
principlesTitle: "Working principles",
|
||||
principles: [
|
||||
"Keep the structure clear enough for future expansion",
|
||||
"Treat performance and clarity as part of the product",
|
||||
"Prepare for deployment and operations from the beginning",
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
kicker: "Let us connect",
|
||||
title: "Contact",
|
||||
description: "Your real contact links will be added later. The placeholders below are ready for direct replacement.",
|
||||
pendingCta: "Link pending",
|
||||
kicker: "Let us start with a clear and actionable conversation",
|
||||
title: "Contact channels",
|
||||
description: "As soon as you add your real links in the environment file, they appear here automatically and become usable for visitors.",
|
||||
availabilityTitle: "Launch readiness",
|
||||
availabilityDescription:
|
||||
"This version is prepared for private-server deployment with an internal health endpoint and contact links managed through configuration.",
|
||||
channelsTitle: "Direct contact links",
|
||||
channelCta: "Open link",
|
||||
channelFallback: "This channel will appear once the real link is added",
|
||||
channels: [
|
||||
{ name: "Email", status: "Real link will be added soon" },
|
||||
//{ name: "LinkedIn", status: "Real link will be added soon" },
|
||||
//{ name: "GitHub", status: "Real link will be added soon" },
|
||||
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
|
||||
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
|
||||
{ key: "github", name: "GitHub", hint: "For repositories, code samples, and project history" },
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
homeTitle: "Coming Soon",
|
||||
homeDescription: "A temporary landing page announcing the upcoming launch of the new personal website.",
|
||||
fullHomeTitle: "Home",
|
||||
fullHomeDescription: "A bilingual professional site presenting technical expertise, project readiness, and server-friendly deployment.",
|
||||
aboutTitle: "About",
|
||||
aboutDescription: "Learn about experience, skills, and the working approach behind modern web builds.",
|
||||
contactTitle: "Contact",
|
||||
contactDescription: "Contact page wired for email and professional profile links through environment settings.",
|
||||
notFoundTitle: "Page not found",
|
||||
notFoundDescription: "The requested page could not be found. Return home or switch to the other language.",
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@ -1,26 +1,39 @@
|
||||
export type CommonContent = {
|
||||
siteTitle: string;
|
||||
siteTagline: string;
|
||||
navLabel: string;
|
||||
languageSwitcherLabel: string;
|
||||
themeToggleLabel: string;
|
||||
themeLight: string;
|
||||
themeDark: string;
|
||||
nav: {
|
||||
home: string;
|
||||
about: string;
|
||||
contact: string;
|
||||
};
|
||||
footerRights: string;
|
||||
footerBuiltWith: string;
|
||||
availabilityBadge: string;
|
||||
};
|
||||
|
||||
export type HomeContent = {
|
||||
export type HomeVariantContent = {
|
||||
badge: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryCta: string;
|
||||
secondaryCta: string;
|
||||
highlights: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type HomeContent = {
|
||||
comingSoon: HomeVariantContent;
|
||||
full: HomeVariantContent;
|
||||
};
|
||||
|
||||
export type AboutContent = {
|
||||
kicker: string;
|
||||
title: string;
|
||||
@ -29,17 +42,39 @@ export type AboutContent = {
|
||||
skills: string[];
|
||||
experienceTitle: string;
|
||||
experience: string[];
|
||||
principlesTitle: string;
|
||||
principles: string[];
|
||||
};
|
||||
|
||||
export type ContactChannelContent = {
|
||||
key: "email" | "linkedin" | "github";
|
||||
name: string;
|
||||
hint: string;
|
||||
};
|
||||
|
||||
export type ContactContent = {
|
||||
kicker: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pendingCta: string;
|
||||
channels: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
availabilityTitle: string;
|
||||
availabilityDescription: string;
|
||||
channelsTitle: string;
|
||||
channelCta: string;
|
||||
channelFallback: string;
|
||||
channels: ContactChannelContent[];
|
||||
};
|
||||
|
||||
export type MetadataContent = {
|
||||
homeTitle: string;
|
||||
homeDescription: string;
|
||||
fullHomeTitle: string;
|
||||
fullHomeDescription: string;
|
||||
aboutTitle: string;
|
||||
aboutDescription: string;
|
||||
contactTitle: string;
|
||||
contactDescription: string;
|
||||
notFoundTitle: string;
|
||||
notFoundDescription: string;
|
||||
};
|
||||
|
||||
export type Dictionary = {
|
||||
@ -47,4 +82,5 @@ export type Dictionary = {
|
||||
home: HomeContent;
|
||||
about: AboutContent;
|
||||
contact: ContactContent;
|
||||
metadata: MetadataContent;
|
||||
};
|
||||
|
||||
@ -14,8 +14,9 @@ services:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000 || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@ -22,3 +22,7 @@ export function getDictionary(locale: Locale): Dictionary {
|
||||
export function getDirection(locale: Locale): "rtl" | "ltr" {
|
||||
return locale === "ar" ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: Locale): string {
|
||||
return locale === "ar" ? "العربية" : "English";
|
||||
}
|
||||
|
||||
60
lib/metadata.ts
Normal file
60
lib/metadata.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary, type Locale } from "@/lib/i18n";
|
||||
import { getLocalizedPath, getLocalizedUrl, isComingSoonMode } from "@/lib/site";
|
||||
|
||||
type PageKey = "home" | "about" | "contact";
|
||||
|
||||
const pagePathMap: Record<PageKey, string> = {
|
||||
home: "",
|
||||
about: "/about",
|
||||
contact: "/contact",
|
||||
};
|
||||
|
||||
export function buildPageMetadata(locale: Locale, page: PageKey): Metadata {
|
||||
const dictionary = getDictionary(locale);
|
||||
const pathname = pagePathMap[page];
|
||||
const isComingSoon = isComingSoonMode();
|
||||
const metadataByPage = {
|
||||
home: {
|
||||
title: isComingSoon ? dictionary.metadata.homeTitle : dictionary.metadata.fullHomeTitle,
|
||||
description: isComingSoon ? dictionary.metadata.homeDescription : dictionary.metadata.fullHomeDescription,
|
||||
},
|
||||
about: {
|
||||
title: dictionary.metadata.aboutTitle,
|
||||
description: dictionary.metadata.aboutDescription,
|
||||
},
|
||||
contact: {
|
||||
title: dictionary.metadata.contactTitle,
|
||||
description: dictionary.metadata.contactDescription,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const pageMetadata = metadataByPage[page];
|
||||
const canonicalPath = getLocalizedPath(pathname, locale);
|
||||
|
||||
return {
|
||||
title: pageMetadata.title,
|
||||
description: pageMetadata.description,
|
||||
alternates: {
|
||||
canonical: canonicalPath,
|
||||
languages: {
|
||||
ar: getLocalizedPath(pathname, "ar"),
|
||||
en: getLocalizedPath(pathname, "en"),
|
||||
"x-default": getLocalizedPath(pathname, "ar"),
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: pageMetadata.title,
|
||||
description: pageMetadata.description,
|
||||
url: getLocalizedUrl(pathname, locale),
|
||||
siteName: dictionary.common.siteTitle,
|
||||
locale: locale === "ar" ? "ar_SA" : "en_US",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: pageMetadata.title,
|
||||
description: pageMetadata.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
94
lib/site.ts
Normal file
94
lib/site.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type { ContactContent } from "@/content/types";
|
||||
|
||||
const FALLBACK_SITE_URL = "https://example.com";
|
||||
const SITE_MODES = ["coming-soon", "full"] as const;
|
||||
|
||||
export type SiteMode = (typeof SITE_MODES)[number];
|
||||
|
||||
function normalizeSiteUrl(value: string | undefined): string {
|
||||
const raw = value?.trim();
|
||||
|
||||
if (!raw) {
|
||||
return FALLBACK_SITE_URL;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return url.origin;
|
||||
} catch {
|
||||
return FALLBACK_SITE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExternalUrl(value: string | undefined): string | undefined {
|
||||
const raw = value?.trim();
|
||||
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(raw).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDisplayValue(key: "email" | "linkedin" | "github", href: string): string {
|
||||
if (key === "email") {
|
||||
return href.replace("mailto:", "");
|
||||
}
|
||||
|
||||
const url = new URL(href);
|
||||
return `${url.hostname}${url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "")}`;
|
||||
}
|
||||
|
||||
export const siteConfig = {
|
||||
ownerName: "Diyaa",
|
||||
siteUrl: normalizeSiteUrl(process.env.NEXT_PUBLIC_SITE_URL),
|
||||
email: process.env.NEXT_PUBLIC_CONTACT_EMAIL?.trim() || "",
|
||||
linkedinUrl: normalizeExternalUrl(process.env.NEXT_PUBLIC_LINKEDIN_URL),
|
||||
githubUrl: normalizeExternalUrl(process.env.NEXT_PUBLIC_GITHUB_URL),
|
||||
};
|
||||
|
||||
export function getSiteMode(): SiteMode {
|
||||
const mode = process.env.NEXT_PUBLIC_SITE_MODE?.trim();
|
||||
return SITE_MODES.includes(mode as SiteMode) ? (mode as SiteMode) : "coming-soon";
|
||||
}
|
||||
|
||||
export function isComingSoonMode(): boolean {
|
||||
return getSiteMode() === "coming-soon";
|
||||
}
|
||||
|
||||
export function getLocalizedPath(pathname: string, locale: "ar" | "en"): string {
|
||||
const normalizedPath = pathname === "/" ? "" : pathname;
|
||||
return `/${locale}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function getLocalizedUrl(pathname: string, locale: "ar" | "en"): string {
|
||||
return new URL(getLocalizedPath(pathname, locale), siteConfig.siteUrl).toString();
|
||||
}
|
||||
|
||||
export function getEmailHref(): string | undefined {
|
||||
return siteConfig.email ? `mailto:${siteConfig.email}` : undefined;
|
||||
}
|
||||
|
||||
export function getContactChannels(contact: ContactContent) {
|
||||
const valueMap = {
|
||||
email: getEmailHref(),
|
||||
linkedin: siteConfig.linkedinUrl,
|
||||
github: siteConfig.githubUrl,
|
||||
} as const;
|
||||
|
||||
return contact.channels.map((channel) => {
|
||||
const href = valueMap[channel.key];
|
||||
const external = channel.key !== "email";
|
||||
|
||||
return {
|
||||
...channel,
|
||||
href,
|
||||
external,
|
||||
value: href ? formatDisplayValue(channel.key, href) : contact.channelFallback,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -2,6 +2,36 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=()",
|
||||
},
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@ -6,7 +6,12 @@
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "npm run lint && npm run typecheck && npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.32",
|
||||
@ -17,6 +22,8 @@
|
||||
"@types/node": "20.14.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "14.2.32",
|
||||
"sass": "1.77.8",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user