Compare commits
No commits in common. "2b5e91377c0e2e45b5c71e62b7a25c278a008885" and "243a182d33a8b3d171a1d7152867e66bd70a16fd" have entirely different histories.
2b5e91377c
...
243a182d33
@ -1,8 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.next
|
|
||||||
node_modules
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
npm-debug.log*
|
|
||||||
README.md
|
|
||||||
11
.env.example
11
.env.example
@ -1,11 +0,0 @@
|
|||||||
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=
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
|
||||||
}
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,7 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.production.local
|
|
||||||
.next
|
|
||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
23
Dockerfile
23
Dockerfile
@ -1,22 +1,10 @@
|
|||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG NEXT_PUBLIC_SITE_URL=https://example.com
|
|
||||||
ARG NEXT_PUBLIC_SITE_MODE=coming-soon
|
|
||||||
ARG NEXT_PUBLIC_CONTACT_EMAIL=
|
|
||||||
ARG NEXT_PUBLIC_LINKEDIN_URL=
|
|
||||||
ARG NEXT_PUBLIC_GITHUB_URL=
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
|
||||||
ENV NEXT_PUBLIC_SITE_MODE=$NEXT_PUBLIC_SITE_MODE
|
|
||||||
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
|
||||||
ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
|
|
||||||
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@ -26,17 +14,6 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ARG NEXT_PUBLIC_SITE_URL=https://example.com
|
|
||||||
ARG NEXT_PUBLIC_SITE_MODE=coming-soon
|
|
||||||
ARG NEXT_PUBLIC_CONTACT_EMAIL=
|
|
||||||
ARG NEXT_PUBLIC_LINKEDIN_URL=
|
|
||||||
ARG NEXT_PUBLIC_GITHUB_URL=
|
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
|
||||||
ENV NEXT_PUBLIC_SITE_MODE=$NEXT_PUBLIC_SITE_MODE
|
|
||||||
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
|
||||||
ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
|
|
||||||
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
|
|
||||||
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|||||||
53
README.md
53
README.md
@ -1,53 +0,0 @@
|
|||||||
# 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,36 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||||
import { buildPageMetadata } from "@/lib/metadata";
|
import { notFound } from "next/navigation";
|
||||||
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 } }) {
|
export default function AboutPage({ params }: { params: { locale: string } }) {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isComingSoonMode()) {
|
|
||||||
redirect(`/${params.locale}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = params.locale as Locale;
|
const locale = params.locale as Locale;
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel section-stack">
|
<section className="panel">
|
||||||
<div>
|
<p className="eyebrow">{dictionary.about.kicker}</p>
|
||||||
<p className="eyebrow">{dictionary.about.kicker}</p>
|
<h1>{dictionary.about.title}</h1>
|
||||||
<h1>{dictionary.about.title}</h1>
|
<p className="lead">{dictionary.about.story}</p>
|
||||||
<p className="lead">{dictionary.about.story}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="split-grid">
|
<div className="split-grid">
|
||||||
<article className="card">
|
<article className="card">
|
||||||
@ -51,15 +34,6 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +1,28 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||||
import { buildPageMetadata } from "@/lib/metadata";
|
import { notFound } from "next/navigation";
|
||||||
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 } }) {
|
export default function ContactPage({ params }: { params: { locale: string } }) {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isComingSoonMode()) {
|
|
||||||
redirect(`/${params.locale}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const locale = params.locale as Locale;
|
const locale = params.locale as Locale;
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
const channels = getContactChannels(dictionary.contact);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel section-stack">
|
<section className="panel">
|
||||||
<div>
|
<p className="eyebrow">{dictionary.contact.kicker}</p>
|
||||||
<p className="eyebrow">{dictionary.contact.kicker}</p>
|
<h1>{dictionary.contact.title}</h1>
|
||||||
<h1>{dictionary.contact.title}</h1>
|
<p className="lead">{dictionary.contact.description}</p>
|
||||||
<p className="lead">{dictionary.contact.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article className="card availability-card">
|
<div className="split-grid">
|
||||||
<h2>{dictionary.contact.availabilityTitle}</h2>
|
{dictionary.contact.channels.map((channel) => (
|
||||||
<p>{dictionary.contact.availabilityDescription}</p>
|
<article className="card" key={channel.name}>
|
||||||
</article>
|
<h2>{channel.name}</h2>
|
||||||
|
<p>{channel.status}</p>
|
||||||
<div className="section-heading">
|
<button type="button" className="pending-btn" disabled aria-disabled="true">
|
||||||
<h2>{dictionary.contact.channelsTitle}</h2>
|
{dictionary.contact.pendingCta}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import SiteFooter from "@/components/SiteFooter";
|
|||||||
import SiteHeader from "@/components/SiteHeader";
|
import SiteHeader from "@/components/SiteHeader";
|
||||||
import { getDictionary, getDirection, isLocale, locales, type Locale } from "@/lib/i18n";
|
import { getDictionary, getDirection, isLocale, locales, type Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
export const dynamicParams = false;
|
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return locales.map((locale) => ({ locale }));
|
return locales.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import HeroSection from "@/components/HeroSection";
|
import HeroSection from "@/components/HeroSection";
|
||||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||||
import { buildPageMetadata } from "@/lib/metadata";
|
|
||||||
import { notFound } from "next/navigation";
|
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 } }) {
|
export default function HomePage({ params }: { params: { locale: string } }) {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
@ -20,5 +10,5 @@ export default function HomePage({ params }: { params: { locale: string } }) {
|
|||||||
const locale = params.locale as Locale;
|
const locale = params.locale as Locale;
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
|
|
||||||
return <HeroSection locale={locale} home={dictionary.home} />;
|
return <HeroSection locale={locale} home={dictionary.home} common={dictionary.common} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
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,7 +61,6 @@ body {
|
|||||||
var(--bg);
|
var(--bg);
|
||||||
font-family: "Space Grotesk", "IBM Plex Sans Arabic", "Cairo", sans-serif;
|
font-family: "Space Grotesk", "IBM Plex Sans Arabic", "Cairo", sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@ -97,55 +96,12 @@ a {
|
|||||||
padding: 0.9rem 0;
|
padding: 0.9rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-block {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.18rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.04em;
|
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 {
|
.theme-toggle {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
||||||
@ -208,24 +164,6 @@ a {
|
|||||||
padding: clamp(1.3rem, 2vw, 2rem);
|
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,
|
.hero h1,
|
||||||
.panel h1 {
|
.panel h1 {
|
||||||
font-size: clamp(1.8rem, 5vw, 3.3rem);
|
font-size: clamp(1.8rem, 5vw, 3.3rem);
|
||||||
@ -256,7 +194,8 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cta-btn,
|
.cta-btn,
|
||||||
.ghost-btn {
|
.ghost-btn,
|
||||||
|
.pending-btn {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 0.74rem 1rem;
|
padding: 0.74rem 1rem;
|
||||||
@ -274,10 +213,12 @@ a {
|
|||||||
background: var(--button-secondary);
|
background: var(--button-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-btn.is-disabled {
|
.pending-btn {
|
||||||
opacity: 0.7;
|
margin-top: 0.6rem;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--button-disabled);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-btn:hover,
|
.cta-btn:hover,
|
||||||
@ -338,61 +279,6 @@ a {
|
|||||||
color: var(--muted);
|
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 {
|
.list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-inline-start: 1.1rem;
|
padding-inline-start: 1.1rem;
|
||||||
@ -414,15 +300,6 @@ a {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-copy p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-badge {
|
.locale-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -502,10 +379,9 @@ a {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 861px) {
|
.lang-toggle {
|
||||||
.bottom-nav {
|
margin-inline-start: 0.18rem;
|
||||||
display: none;
|
border-color: var(--line) !important;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-up {
|
@keyframes fade-up {
|
||||||
@ -520,16 +396,6 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.bar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-grid,
|
.split-grid,
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -1,34 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { siteConfig } from "@/lib/site";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(siteConfig.siteUrl),
|
title: "diyaa | Personal Website",
|
||||||
title: {
|
description: "Bilingual personal website built with Next.js",
|
||||||
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 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 {
|
try {
|
||||||
const storedTheme = localStorage.getItem("theme");
|
const storedTheme = localStorage.getItem("theme");
|
||||||
const activeTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
|
const activeTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
|
||||||
@ -41,7 +21,7 @@ const themeScript = `
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ar" dir="rtl" data-theme="dark" suppressHydrationWarning>
|
<html lang="ar" data-theme="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -3,8 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type { CommonContent } from "@/content/types";
|
import type { CommonContent } from "@/content/types";
|
||||||
import { type Locale } from "@/lib/i18n";
|
import { locales, type Locale } from "@/lib/i18n";
|
||||||
import { isComingSoonMode } from "@/lib/site";
|
|
||||||
|
|
||||||
type BottomNavProps = {
|
type BottomNavProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@ -13,31 +12,33 @@ type BottomNavProps = {
|
|||||||
|
|
||||||
export default function BottomNav({ locale, common }: BottomNavProps) {
|
export default function BottomNav({ locale, common }: BottomNavProps) {
|
||||||
const pathname = usePathname();
|
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 normalizedPath = pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||||
const homePath = `/${locale}`;
|
const homePath = `/${locale}`;
|
||||||
const aboutPath = `/${locale}/about`;
|
const aboutPath = `/${locale}/about`;
|
||||||
const contactPath = `/${locale}/contact`;
|
const contactPath = `/${locale}/contact`;
|
||||||
|
|
||||||
const isHome = normalizedPath === homePath;
|
const isHome = normalizedPath === homePath;
|
||||||
const isAbout = normalizedPath === aboutPath || normalizedPath.startsWith(`${aboutPath}/`);
|
const isAbout = normalizedPath === aboutPath || normalizedPath.startsWith(`${aboutPath}/`);
|
||||||
const isContact = normalizedPath === contactPath || normalizedPath.startsWith(`${contactPath}/`);
|
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 (
|
return (
|
||||||
<nav aria-label={common.navLabel} className="bottom-nav">
|
<nav aria-label={common.navLabel} className="bottom-nav">
|
||||||
@ -65,6 +66,12 @@ export default function BottomNav({ locale, common }: BottomNavProps) {
|
|||||||
</span>
|
</span>
|
||||||
{common.nav.contact}
|
{common.nav.contact}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={switchPath()} className="lang-toggle" aria-label={common.languageSwitcherLabel}>
|
||||||
|
<span aria-hidden className="nav-icon">
|
||||||
|
⇄
|
||||||
|
</span>
|
||||||
|
{targetLocale.toUpperCase()}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,41 +1,31 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { HomeContent, HomeVariantContent } from "@/content/types";
|
import type { CommonContent, HomeContent } from "@/content/types";
|
||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
import { getEmailHref, getModeValue, isComingSoonMode } from "@/lib/site";
|
|
||||||
|
|
||||||
type HeroSectionProps = {
|
type HeroSectionProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
home: HomeContent;
|
home: HomeContent;
|
||||||
|
common: CommonContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HeroSection({ locale, home }: HeroSectionProps) {
|
export default function HeroSection({ locale, home, common }: HeroSectionProps) {
|
||||||
const activeHome: HomeVariantContent = getModeValue(home);
|
|
||||||
const emailHref = getEmailHref();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="hero panel">
|
<section className="hero panel">
|
||||||
<p className="availability-badge">{activeHome.badge}</p>
|
<p className="eyebrow">{home.kicker}</p>
|
||||||
<p className="eyebrow">{activeHome.kicker}</p>
|
<h1>{home.title}</h1>
|
||||||
<h1>{activeHome.title}</h1>
|
<p className="lead">{home.description}</p>
|
||||||
<p className="lead">{activeHome.description}</p>
|
|
||||||
|
|
||||||
<div className="cta-row">
|
<div className="cta-row">
|
||||||
{emailHref ? (
|
<Link href={`/${locale}/contact`} className="cta-btn">
|
||||||
<a href={emailHref} className="cta-btn">
|
{home.primaryCta}
|
||||||
{activeHome.primaryCta}
|
</Link>
|
||||||
</a>
|
<Link href={`/${locale}/about`} className="ghost-btn">
|
||||||
) : (
|
{common.nav.about}
|
||||||
<span className="cta-btn is-disabled" aria-disabled="true">
|
|
||||||
{activeHome.primaryCta}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Link href={isComingSoonMode() ? `/${locale}` : `/${locale}/about`} className="ghost-btn">
|
|
||||||
{activeHome.secondaryCta}
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
{activeHome.highlights.map((item) => (
|
{home.highlights.map((item) => (
|
||||||
<article key={item.label} className="stat-card">
|
<article key={item.label} className="stat-card">
|
||||||
<span className="stat-value">{item.value}</span>
|
<span className="stat-value">{item.value}</span>
|
||||||
<span className="stat-label">{item.label}</span>
|
<span className="stat-label">{item.label}</span>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export default function LanguageSwitcher({ locale, label }: LanguageSwitcherProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="language-switcher" role="group" aria-label={label}>
|
<div className="language-switcher" aria-label={label}>
|
||||||
{locales.map((item) => {
|
{locales.map((item) => {
|
||||||
const active = item === locale;
|
const active = item === locale;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
import type { CommonContent } from "@/content/types";
|
import type { CommonContent } from "@/content/types";
|
||||||
import { getModeValue } from "@/lib/site";
|
|
||||||
|
|
||||||
type SiteFooterProps = {
|
type SiteFooterProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@ -9,15 +8,11 @@ type SiteFooterProps = {
|
|||||||
|
|
||||||
export default function SiteFooter({ locale, common }: SiteFooterProps) {
|
export default function SiteFooter({ locale, common }: SiteFooterProps) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const commonVariant = getModeValue(common.variants);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="site-footer">
|
<footer className="site-footer">
|
||||||
<div className="container footer-content">
|
<div className="container footer-content">
|
||||||
<div className="footer-copy">
|
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
||||||
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
|
||||||
<p>{commonVariant.footerBuiltWith}</p>
|
|
||||||
</div>
|
|
||||||
<p className="locale-badge">{locale.toUpperCase()}</p>
|
<p className="locale-badge">{locale.toUpperCase()}</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
|
||||||
import ThemeToggle from "@/components/ThemeToggle";
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
import type { CommonContent } from "@/content/types";
|
import type { CommonContent } from "@/content/types";
|
||||||
import { getModeValue, isComingSoonMode } from "@/lib/site";
|
|
||||||
|
|
||||||
type SiteHeaderProps = {
|
type SiteHeaderProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@ -11,35 +9,13 @@ type SiteHeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SiteHeader({ locale, common }: SiteHeaderProps) {
|
export default function SiteHeader({ locale, common }: SiteHeaderProps) {
|
||||||
const isComingSoon = isComingSoonMode();
|
|
||||||
const commonVariant = getModeValue(common.variants);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="container bar">
|
<div className="container bar">
|
||||||
<div className="brand-block">
|
<Link href={`/${locale}`} className="brand">
|
||||||
<Link href={`/${locale}`} className="brand">
|
{common.siteTitle}
|
||||||
{common.siteTitle}
|
</Link>
|
||||||
</Link>
|
<ThemeToggle />
|
||||||
<span className="brand-tagline">{commonVariant.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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,17 +6,11 @@ type Theme = "dark" | "light";
|
|||||||
|
|
||||||
const STORAGE_KEY = "theme";
|
const STORAGE_KEY = "theme";
|
||||||
|
|
||||||
type ThemeToggleProps = {
|
|
||||||
label: string;
|
|
||||||
lightLabel: string;
|
|
||||||
darkLabel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isTheme(value: string | null): value is Theme {
|
function isTheme(value: string | null): value is Theme {
|
||||||
return value === "dark" || value === "light";
|
return value === "dark" || value === "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThemeToggle({ label, lightLabel, darkLabel }: ThemeToggleProps) {
|
export default function ThemeToggle() {
|
||||||
const [theme, setTheme] = useState<Theme>("dark");
|
const [theme, setTheme] = useState<Theme>("dark");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -40,9 +34,9 @@ export default function ThemeToggle({ label, lightLabel, darkLabel }: ThemeToggl
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
aria-label={`${label}: ${theme === "dark" ? lightLabel : darkLabel}`}
|
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||||
>
|
>
|
||||||
<span className="theme-toggle-label">{theme === "dark" ? lightLabel : darkLabel}</span>
|
<span className="theme-toggle-label">{theme === "dark" ? "Light" : "Dark"}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
111
content/ar.ts
111
content/ar.ts
@ -5,117 +5,50 @@ const ar: Dictionary = {
|
|||||||
siteTitle: "ضياء",
|
siteTitle: "ضياء",
|
||||||
navLabel: "التنقل الرئيسي",
|
navLabel: "التنقل الرئيسي",
|
||||||
languageSwitcherLabel: "تبديل اللغة",
|
languageSwitcherLabel: "تبديل اللغة",
|
||||||
themeToggleLabel: "نمط الواجهة",
|
|
||||||
themeLight: "فاتح",
|
|
||||||
themeDark: "داكن",
|
|
||||||
nav: {
|
nav: {
|
||||||
home: "الرئيسية",
|
home: "الرئيسية",
|
||||||
about: "من أنا",
|
about: "من أنا",
|
||||||
contact: "تواصل",
|
contact: "تواصل",
|
||||||
},
|
},
|
||||||
footerRights: "{year} جميع الحقوق محفوظة",
|
footerRights: "{year} جميع الحقوق محفوظة",
|
||||||
variants: {
|
|
||||||
comingSoon: {
|
|
||||||
siteTagline: "موقع شخصي جديد قيد الإطلاق",
|
|
||||||
footerBuiltWith: "صفحة مؤقتة للإعلان عن قرب إطلاق الموقع الكامل على الخادم الخاص.",
|
|
||||||
},
|
|
||||||
full: {
|
|
||||||
siteTagline: "مطور ويب يبني تجارب سريعة وقابلة للتوسع",
|
|
||||||
footerBuiltWith: "موقع ثنائي اللغة مهيأ للعرض المهني والنشر على خادم خاص.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
comingSoon: {
|
kicker: "Full-Stack Developer",
|
||||||
badge: "Coming Soon",
|
title: "أبني أنظمة ويب حديثة وقابلة للتوسع",
|
||||||
kicker: "قريبًا",
|
description:
|
||||||
title: "موقعي الجديد قيد التجهيز",
|
"مطور Full-Stack أعمل على بناء تطبيقات ويب حديثة باستخدام Next.js و NestJS و PostgreSQL. أركز على الأنظمة القابلة للتوسع وتجارب المستخدم السريعة.",
|
||||||
description:
|
primaryCta: "تواصل معي",
|
||||||
"أعمل حاليًا على إطلاق نسخة جديدة بهوية أوضح وتجربة أسرع ومحتوى أكثر اكتمالًا. الصفحة الكاملة ستتوفر قريبًا بعد إنهاء اللمسات الأخيرة.",
|
highlights: [
|
||||||
primaryCta: "تواصل عند الإطلاق",
|
{ value: "+10", label: "مشاريع برمجية" },
|
||||||
secondaryCta: "تابع قريبًا",
|
{ value: "Next.js / NestJS", label: "Stack الأساسي" },
|
||||||
highlights: [
|
{ value: "Docker", label: "Dev Infrastructure" },
|
||||||
{ value: "نسخة جديدة", label: "واجهة ومحتوى محدثان" },
|
],
|
||||||
{ value: "ثنائي اللغة", label: "عربي وإنجليزي" },
|
|
||||||
{ value: "قريبًا", label: "الإطلاق الرسمي" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
full: {
|
|
||||||
badge: "متاح لمشاريع الويب والتطوير المخصص",
|
|
||||||
kicker: "مطور ويب متكامل",
|
|
||||||
title: "أبني مواقع وأنظمة ويب جاهزة للنمو",
|
|
||||||
description:
|
|
||||||
"أصمم وأطوّر واجهات وتجارب رقمية متماسكة، وأربطها ببنية تشغيل واضحة تجعل الإطلاق والصيانة والتوسع أسهل على المدى الطويل.",
|
|
||||||
primaryCta: "ابدأ التواصل",
|
|
||||||
secondaryCta: "اعرف المزيد",
|
|
||||||
highlights: [
|
|
||||||
{ value: "+10", label: "نماذج ومشاريع مطورة" },
|
|
||||||
{ value: "ثنائي اللغة", label: "عربي وإنجليزي" },
|
|
||||||
{ value: "جاهز للنشر", label: "Docker و VPS" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
kicker: "نبذة سريعة",
|
kicker: "نبذة سريعة",
|
||||||
title: "من أنا",
|
title: "من أنا",
|
||||||
story:
|
story:
|
||||||
"أعمل على تحويل الأفكار إلى مواقع ومنتجات ويب واضحة وسريعة ومرنة. تركيزي الأساسي يكون على الجمع بين تجربة استخدام مرتبة، كود منظم، وتجهيز فعلي للنشر حتى لا يبقى المشروع جميلًا فقط بل قابلًا للاعتماد والتطوير لاحقًا.",
|
"أنا مطور Full-Stack أركز على بناء تطبيقات ويب حديثة وقابلة للتوسع. أعمل بشكل أساسي باستخدام Next.js و NestJS و PostgreSQL مع بنية تشغيل تعتمد على Docker. أركز في مشاريعي على الأداء، البنية النظيفة، وتجربة المستخدم السريعة، مع تصميم أنظمة يمكن تطويرها بسهولة مع نمو المشروع.",
|
||||||
skillsTitle: "مهارات",
|
skillsTitle: "مهارات",
|
||||||
skills: [
|
skills: ["تطوير واجهات", "تحسين تجربة المستخدم", "تنظيم المحتوى", "إدارة مشاريع رقمية"],
|
||||||
"Next.js و React",
|
|
||||||
"TypeScript وتنظيم الواجهات",
|
|
||||||
"تصميم واجهات مرنة وسريعة",
|
|
||||||
"تهيئة النشر على الخوادم الخاصة",
|
|
||||||
"تحسين الأداء وتجربة الاستخدام",
|
|
||||||
"تنظيم المحتوى متعدد اللغات",
|
|
||||||
],
|
|
||||||
experienceTitle: "خبرات مختصرة",
|
experienceTitle: "خبرات مختصرة",
|
||||||
experience: [
|
experience: [
|
||||||
"بناء مواقع تعريفية وصفحات هبوط وهوية رقمية مهنية",
|
"HTML",
|
||||||
"تطوير واجهات تطبيقات حديثة قابلة للتوسع",
|
"CSS , SCSS",
|
||||||
"ربط الواجهة بتدفق نشر واضح باستخدام Docker",
|
"React",
|
||||||
],
|
|
||||||
principlesTitle: "منهج العمل",
|
|
||||||
principles: [
|
|
||||||
"بنية واضحة تسهّل التوسع والصيانة لاحقًا",
|
|
||||||
"اهتمام مبكر بالأداء ووضوح الرسالة داخل الواجهة",
|
|
||||||
"تجهيز عملي للنشر، الصحة التشغيلية، والاعتماد على ملفات البيئة",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
kicker: "لنبدأ مشروعًا واضحًا وقابلًا للتنفيذ",
|
kicker: "لنبدأ خطوة جديدة",
|
||||||
title: "قنوات التواصل",
|
title: "تواصل",
|
||||||
description: "بمجرد إضافة روابطك الحقيقية في ملف البيئة ستظهر هنا تلقائيًا وتصبح جاهزة للاستخدام المباشر من الزوار.",
|
description: "روابط التواصل ستتم إضافتها لاحقًا. كل العناصر أدناه جاهزة لاستقبال الروابط الفعلية.",
|
||||||
availabilityTitle: "جاهزية الإطلاق",
|
pendingCta: "الرابط قيد الإضافة",
|
||||||
availabilityDescription:
|
|
||||||
"النسخة الحالية مهيأة للنشر على خادم خاص مع فحص صحة داخلي وروابط تواصل قابلة للإدارة من الإعدادات.",
|
|
||||||
channelsTitle: "روابطك المباشرة",
|
|
||||||
channelCta: "فتح الرابط",
|
|
||||||
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
|
|
||||||
channels: [
|
channels: [
|
||||||
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
|
{ name: "Email", status: "سيتم إضافة الرابط قريبًا" },
|
||||||
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
|
{ name: "LinkedIn", status: "سيتم إضافة الرابط قريبًا" },
|
||||||
{ key: "github", name: "جيت هب", hint: "لاستعراض المشاريع والمستودعات البرمجية" },
|
{ name: "GitHub", status: "سيتم إضافة الرابط قريبًا" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
metadata: {
|
|
||||||
home: {
|
|
||||||
comingSoon: {
|
|
||||||
title: "قريبًا",
|
|
||||||
description: "صفحة ترحيبية مؤقتة للإعلان عن قرب إطلاق الموقع الشخصي الجديد.",
|
|
||||||
},
|
|
||||||
full: {
|
|
||||||
title: "الصفحة الرئيسية",
|
|
||||||
description: "موقع شخصي احترافي ثنائي اللغة يعرض الهوية المهنية والخبرة التقنية والاستعداد للنشر.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
aboutTitle: "من أنا",
|
|
||||||
aboutDescription: "تعرف على الخبرات، المهارات، ومنهج العمل في بناء مواقع وأنظمة ويب حديثة.",
|
|
||||||
contactTitle: "تواصل",
|
|
||||||
contactDescription: "صفحة تواصل جاهزة لعرض البريد وروابط المنصات المهنية مباشرة من الإعدادات.",
|
|
||||||
notFoundTitle: "الصفحة غير موجودة",
|
|
||||||
notFoundDescription: "تعذر العثور على الصفحة المطلوبة. يمكنك العودة إلى الصفحة الرئيسية أو التبديل بين اللغتين.",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ar;
|
export default ar;
|
||||||
|
|||||||
115
content/en.ts
115
content/en.ts
@ -2,120 +2,53 @@ import type { Dictionary } from "@/content/types";
|
|||||||
|
|
||||||
const en: Dictionary = {
|
const en: Dictionary = {
|
||||||
common: {
|
common: {
|
||||||
siteTitle: "Diyaa",
|
siteTitle: "diyaa | Personal Site",
|
||||||
navLabel: "Main navigation",
|
navLabel: "Main navigation",
|
||||||
languageSwitcherLabel: "Switch language",
|
languageSwitcherLabel: "Switch language",
|
||||||
themeToggleLabel: "Interface theme",
|
|
||||||
themeLight: "Light",
|
|
||||||
themeDark: "Dark",
|
|
||||||
nav: {
|
nav: {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
about: "About",
|
about: "About Me",
|
||||||
contact: "Contact",
|
contact: "Contact",
|
||||||
},
|
},
|
||||||
footerRights: "{year} All rights reserved",
|
footerRights: "{year} All rights reserved",
|
||||||
variants: {
|
|
||||||
comingSoon: {
|
|
||||||
siteTagline: "A new personal site is on the way",
|
|
||||||
footerBuiltWith: "A temporary landing page announcing the upcoming launch on the private server.",
|
|
||||||
},
|
|
||||||
full: {
|
|
||||||
siteTagline: "Web developer building fast, scalable digital experiences",
|
|
||||||
footerBuiltWith: "A bilingual professional site prepared for deployment on a private server.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
comingSoon: {
|
kicker: "Fresh personal presence",
|
||||||
badge: "Coming Soon",
|
title: "A bold digital space for my professional identity",
|
||||||
kicker: "Coming soon",
|
description:
|
||||||
title: "A fresh version of this site is being prepared",
|
"This is placeholder copy you can replace quickly. It is built to highlight your profile, communicate value, and stay clean on every screen size.",
|
||||||
description:
|
primaryCta: "Start a conversation",
|
||||||
"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.",
|
highlights: [
|
||||||
primaryCta: "Reach out when it launches",
|
{ value: "3", label: "Core pages" },
|
||||||
secondaryCta: "Stay tuned",
|
{ value: "RTL/LTR", label: "Direction ready" },
|
||||||
highlights: [
|
{ value: "100%", label: "Customizable" },
|
||||||
{ 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: {
|
about: {
|
||||||
kicker: "Short introduction",
|
kicker: "Short introduction",
|
||||||
title: "About me",
|
title: "About me",
|
||||||
story:
|
story:
|
||||||
"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.",
|
"This is temporary biography text. Replace it with your real story, strengths, and the kind of outcomes you deliver through your work.",
|
||||||
skillsTitle: "Skills",
|
skillsTitle: "Skills",
|
||||||
skills: [
|
skills: ["Frontend engineering", "UX refinement", "Content structuring", "Digital project delivery"],
|
||||||
"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",
|
experienceTitle: "Experience highlights",
|
||||||
experience: [
|
experience: [
|
||||||
"Professional landing pages and personal web presence builds",
|
"Placeholder experience line 1",
|
||||||
"Scalable frontend interfaces for modern web apps",
|
"Placeholder experience line 2",
|
||||||
"Docker-based delivery flows for cleaner deployment",
|
"Placeholder experience line 3",
|
||||||
],
|
|
||||||
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: {
|
contact: {
|
||||||
kicker: "Let us start with a clear and actionable conversation",
|
kicker: "Let us connect",
|
||||||
title: "Contact channels",
|
title: "Contact",
|
||||||
description: "As soon as you add your real links in the environment file, they appear here automatically and become usable for visitors.",
|
description: "Your real contact links will be added later. The placeholders below are ready for direct replacement.",
|
||||||
availabilityTitle: "Launch readiness",
|
pendingCta: "Link pending",
|
||||||
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: [
|
channels: [
|
||||||
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
|
{ name: "Email", status: "Real link will be added soon" },
|
||||||
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
|
//{ name: "LinkedIn", status: "Real link will be added soon" },
|
||||||
{ key: "github", name: "GitHub", hint: "For repositories, code samples, and project history" },
|
//{ name: "GitHub", status: "Real link will be added soon" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
metadata: {
|
|
||||||
home: {
|
|
||||||
comingSoon: {
|
|
||||||
title: "Coming Soon",
|
|
||||||
description: "A temporary landing page announcing the upcoming launch of the new personal website.",
|
|
||||||
},
|
|
||||||
full: {
|
|
||||||
title: "Home",
|
|
||||||
description: "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;
|
export default en;
|
||||||
|
|||||||
@ -1,44 +1,26 @@
|
|||||||
export type ModeVariants<T> = {
|
|
||||||
comingSoon: T;
|
|
||||||
full: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CommonVariantContent = {
|
|
||||||
siteTagline: string;
|
|
||||||
footerBuiltWith: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CommonContent = {
|
export type CommonContent = {
|
||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
navLabel: string;
|
navLabel: string;
|
||||||
languageSwitcherLabel: string;
|
languageSwitcherLabel: string;
|
||||||
themeToggleLabel: string;
|
|
||||||
themeLight: string;
|
|
||||||
themeDark: string;
|
|
||||||
nav: {
|
nav: {
|
||||||
home: string;
|
home: string;
|
||||||
about: string;
|
about: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
};
|
};
|
||||||
footerRights: string;
|
footerRights: string;
|
||||||
variants: ModeVariants<CommonVariantContent>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HomeVariantContent = {
|
export type HomeContent = {
|
||||||
badge: string;
|
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
primaryCta: string;
|
primaryCta: string;
|
||||||
secondaryCta: string;
|
|
||||||
highlights: Array<{
|
highlights: Array<{
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HomeContent = ModeVariants<HomeVariantContent>;
|
|
||||||
|
|
||||||
export type AboutContent = {
|
export type AboutContent = {
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -47,41 +29,17 @@ export type AboutContent = {
|
|||||||
skills: string[];
|
skills: string[];
|
||||||
experienceTitle: string;
|
experienceTitle: string;
|
||||||
experience: string[];
|
experience: string[];
|
||||||
principlesTitle: string;
|
|
||||||
principles: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContactChannelContent = {
|
|
||||||
key: "email" | "linkedin" | "github";
|
|
||||||
name: string;
|
|
||||||
hint: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContactContent = {
|
export type ContactContent = {
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
availabilityTitle: string;
|
pendingCta: string;
|
||||||
availabilityDescription: string;
|
channels: Array<{
|
||||||
channelsTitle: string;
|
name: string;
|
||||||
channelCta: string;
|
status: string;
|
||||||
channelFallback: string;
|
}>;
|
||||||
channels: ContactChannelContent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MetadataVariantContent = {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MetadataContent = {
|
|
||||||
home: ModeVariants<MetadataVariantContent>;
|
|
||||||
aboutTitle: string;
|
|
||||||
aboutDescription: string;
|
|
||||||
contactTitle: string;
|
|
||||||
contactDescription: string;
|
|
||||||
notFoundTitle: string;
|
|
||||||
notFoundDescription: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Dictionary = {
|
export type Dictionary = {
|
||||||
@ -89,5 +47,4 @@ export type Dictionary = {
|
|||||||
home: HomeContent;
|
home: HomeContent;
|
||||||
about: AboutContent;
|
about: AboutContent;
|
||||||
contact: ContactContent;
|
contact: ContactContent;
|
||||||
metadata: MetadataContent;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,12 +5,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://example.com}
|
|
||||||
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
|
||||||
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
|
||||||
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
|
||||||
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
|
|
||||||
container_name: ${CONTAINER_NAME:-diyaa}
|
container_name: ${CONTAINER_NAME:-diyaa}
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-30002}:3000"
|
- "${APP_PORT:-30002}:3000"
|
||||||
@ -20,14 +14,8 @@ services:
|
|||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1}
|
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://example.com}
|
|
||||||
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
|
||||||
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
|
||||||
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
|
||||||
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000 || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@ -22,7 +22,3 @@ export function getDictionary(locale: Locale): Dictionary {
|
|||||||
export function getDirection(locale: Locale): "rtl" | "ltr" {
|
export function getDirection(locale: Locale): "rtl" | "ltr" {
|
||||||
return locale === "ar" ? "rtl" : "ltr";
|
return locale === "ar" ? "rtl" : "ltr";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocaleName(locale: Locale): string {
|
|
||||||
return locale === "ar" ? "العربية" : "English";
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { getDictionary, type Locale } from "@/lib/i18n";
|
|
||||||
import { getLocalizedPath, getLocalizedUrl, getModeValue } 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 homeMetadata = getModeValue(dictionary.metadata.home);
|
|
||||||
const metadataByPage = {
|
|
||||||
home: {
|
|
||||||
title: homeMetadata.title,
|
|
||||||
description: homeMetadata.description,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
102
lib/site.ts
102
lib/site.ts
@ -1,102 +0,0 @@
|
|||||||
import type { ContactContent, ModeVariants } 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";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModeVariantKey(mode: SiteMode): keyof ModeVariants<unknown> {
|
|
||||||
return mode === "coming-soon" ? "comingSoon" : "full";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModeValue<T>(variants: ModeVariants<T>): T {
|
|
||||||
return variants[getModeVariantKey(getSiteMode())];
|
|
||||||
}
|
|
||||||
|
|
||||||
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,36 +2,6 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
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;
|
module.exports = nextConfig;
|
||||||
|
|||||||
@ -6,12 +6,7 @@
|
|||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"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": {
|
"dependencies": {
|
||||||
"next": "14.2.32",
|
"next": "14.2.32",
|
||||||
@ -22,8 +17,6 @@
|
|||||||
"@types/node": "20.14.12",
|
"@types/node": "20.14.12",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"eslint": "8.57.1",
|
|
||||||
"eslint-config-next": "14.2.32",
|
|
||||||
"sass": "1.77.8",
|
"sass": "1.77.8",
|
||||||
"typescript": "5.5.4"
|
"typescript": "5.5.4"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user