diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3076457 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.next +node_modules +.env +.env.local +npm-debug.log* +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fec5857 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..196c89e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +.env.local +.env.production.local +.next +node_modules +npm-debug.log* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/Dockerfile b/Dockerfile index 7c85348..e42f748 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..42fd8b1 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/[locale]/about/page.tsx b/app/[locale]/about/page.tsx index 0c170f9..4668238 100644 --- a/app/[locale]/about/page.tsx +++ b/app/[locale]/about/page.tsx @@ -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 ( -
-

{dictionary.about.kicker}

-

{dictionary.about.title}

-

{dictionary.about.story}

+
+
+

{dictionary.about.kicker}

+

{dictionary.about.title}

+

{dictionary.about.story}

+
@@ -34,6 +51,15 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
+ +
+

{dictionary.about.principlesTitle}

+
    + {dictionary.about.principles.map((item) => ( +
  • {item}
  • + ))} +
+
); } diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index 3b27d2a..5a6a5dd 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -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 ( -
-

{dictionary.contact.kicker}

-

{dictionary.contact.title}

-

{dictionary.contact.description}

+
+
+

{dictionary.contact.kicker}

+

{dictionary.contact.title}

+

{dictionary.contact.description}

+
-
- {dictionary.contact.channels.map((channel) => ( -
-

{channel.name}

-

{channel.status}

- +
+

{dictionary.contact.availabilityTitle}

+

{dictionary.contact.availabilityDescription}

+
+ +
+

{dictionary.contact.channelsTitle}

+
+ +
+ {channels.map((channel) => ( + ))}
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 5198160..8898c0f 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -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 })); } diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 53bfdb3..0a26b45 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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(); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..2551c84 --- /dev/null +++ b/app/api/health/route.ts @@ -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", + }, + }, + ); +} diff --git a/app/globals.css b/app/globals.css index 944dec3..a47222c 100644 --- a/app/globals.css +++ b/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; diff --git a/app/layout.tsx b/app/layout.tsx index e24f40c..04c5442 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - +