This commit is contained in:
eric
2026-05-20 18:29:19 -05:00
parent 9f4bf1b28c
commit 6809f833f8
31 changed files with 5963 additions and 78 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

100
README.md
View File

@@ -1,92 +1,36 @@
# nomadroen
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://gitlab.nomadro.com/gitlab-instance-0a899031/nomadroen.git
git branch -M main
git push -uf origin main
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
## Integrate with your tools
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- [ ] [Set up project integrations](http://gitlab.nomadro.com/gitlab-instance-0a899031/nomadroen/-/settings/integrations)
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Collaborate with your team
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Learn More
## Test and Deploy
To learn more about Next.js, take a look at the following resources:
Use the built-in continuous integration in GitLab.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
***
## Deploy on Vercel
# Editing this README
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

377
app/globals.css Normal file
View File

@@ -0,0 +1,377 @@
@import "tailwindcss";
:root {
--background: #f4f7fb;
--foreground: #0c1929;
--muted: #5b6b7c;
--accent: #0d9488;
--accent-secondary: #ea580c;
--accent-tertiary: #0284c7;
--surface: rgba(255, 255, 255, 0.78);
--surface-solid: #ffffff;
--border: rgba(13, 148, 136, 0.14);
--gradient-1: #0d9488;
--gradient-2: #f59e0b;
--gradient-3: #0284c7;
--glow-sm: 0 8px 32px rgba(13, 148, 136, 0.2);
--glow-lg: 0 24px 64px rgba(234, 88, 12, 0.18);
--mesh-1: rgba(13, 148, 136, 0.32);
--mesh-2: rgba(245, 158, 11, 0.26);
--mesh-3: rgba(2, 132, 199, 0.22);
--prose: #334155;
--card-shadow: 0 4px 24px rgba(12, 25, 41, 0.07);
--callout-bg: rgba(13, 148, 136, 0.08);
--callout-border: rgba(13, 148, 136, 0.22);
--read-max: 42rem;
--header-h: 4.25rem;
}
.dark {
--background: #060d18;
--foreground: #e8eef4;
--muted: #8fa3b8;
--accent: #2dd4bf;
--accent-secondary: #fb923c;
--accent-tertiary: #38bdf8;
--surface: rgba(12, 25, 41, 0.72);
--surface-solid: #0c1929;
--border: rgba(45, 212, 191, 0.14);
--gradient-1: #14b8a6;
--gradient-2: #fb923c;
--gradient-3: #38bdf8;
--glow-sm: 0 8px 32px rgba(20, 184, 166, 0.22);
--glow-lg: 0 24px 64px rgba(251, 146, 60, 0.16);
--mesh-1: rgba(20, 184, 166, 0.35);
--mesh-2: rgba(251, 146, 60, 0.28);
--mesh-3: rgba(56, 189, 248, 0.25);
--prose: #b8c5d4;
--card-shadow: 0 4px 28px rgba(0, 0, 0, 0.4);
--callout-bg: rgba(20, 184, 166, 0.1);
--callout-border: rgba(45, 212, 191, 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-surface: var(--surface);
--color-border: var(--border);
--font-sans: var(--font-dm-sans);
--font-serif: var(--font-source-serif);
--font-display: var(--font-plus-jakarta);
}
html {
color-scheme: light;
scroll-padding-top: calc(var(--header-h) + 1rem);
}
html.dark {
color-scheme: dark;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-dm-sans), system-ui, sans-serif;
}
/* Latitude grid + ambient mesh */
.mesh-bg {
position: fixed;
inset: 0;
z-index: -2;
overflow: hidden;
pointer-events: none;
background-image:
linear-gradient(rgba(13, 148, 136, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(13, 148, 136, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
}
.dark .mesh-bg {
background-image:
linear-gradient(rgba(45, 212, 191, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(45, 212, 191, 0.06) 1px, transparent 1px);
}
.mesh-bg::before,
.mesh-bg::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(88px);
animation: mesh-float 20s ease-in-out infinite alternate;
}
.mesh-bg::before {
width: min(72vw, 540px);
height: min(72vw, 540px);
top: -14%;
right: -6%;
background: radial-gradient(circle, var(--mesh-1), transparent 68%);
}
.mesh-bg::after {
width: min(58vw, 420px);
height: min(58vw, 420px);
bottom: 8%;
left: -12%;
background: radial-gradient(circle, var(--mesh-2), transparent 70%);
animation-delay: -7s;
}
.mesh-orb {
position: absolute;
border-radius: 50%;
filter: blur(76px);
animation: mesh-float 24s ease-in-out infinite alternate-reverse;
}
.mesh-orb--sky {
width: min(48vw, 340px);
height: min(48vw, 340px);
top: 42%;
left: 38%;
background: radial-gradient(circle, var(--mesh-3), transparent 70%);
}
@keyframes mesh-float {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(20px, -28px) scale(1.06);
}
}
.gradient-text {
background: linear-gradient(
125deg,
var(--gradient-1) 0%,
var(--gradient-2) 45%,
var(--gradient-3) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.glass-card {
background: var(--surface);
border: 1px solid var(--border);
backdrop-filter: blur(16px);
box-shadow: var(--card-shadow);
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 3rem;
padding-inline: 1.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
color: white;
background: linear-gradient(
125deg,
var(--gradient-1),
var(--gradient-2),
var(--gradient-3)
);
background-size: 200% 200%;
box-shadow: var(--glow-sm);
transition: transform 0.2s, box-shadow 0.2s, background-position 0.4s;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: var(--glow-lg);
background-position: 100% 50%;
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 3rem;
padding-inline: 1.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
color: var(--foreground);
background: var(--surface);
border: 1px solid var(--border);
backdrop-filter: blur(12px);
transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
}
.btn-secondary:hover {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
box-shadow: var(--glow-sm);
transform: translateY(-1px);
}
.font-display {
font-family: var(--font-plus-jakarta), var(--font-dm-sans), system-ui, sans-serif;
}
.cover-float {
animation: cover-float 7s ease-in-out infinite;
}
@keyframes cover-float {
0%,
100% {
transform: translateY(0) rotate(-0.5deg);
}
50% {
transform: translateY(-12px) rotate(0.5deg);
}
}
/* Hub marquee */
.marquee-track {
display: flex;
width: max-content;
animation: marquee 42s linear infinite;
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
/* Reading progress */
.reading-progress {
position: fixed;
top: var(--header-h);
left: 0;
z-index: 40;
height: 3px;
width: 100%;
background: transparent;
pointer-events: none;
}
.reading-progress__bar {
height: 100%;
width: 0%;
background: linear-gradient(
90deg,
var(--gradient-1),
var(--gradient-2),
var(--gradient-3)
);
transition: width 0.1s linear;
box-shadow: 0 0 12px color-mix(in srgb, var(--gradient-1) 50%, transparent);
}
/* Prose */
.prose-ebook {
font-size: 1.0625rem;
line-height: 1.88;
}
.prose-ebook h2 {
font-family: var(--font-source-serif), Georgia, serif;
font-size: clamp(1.4rem, 3.2vw, 1.75rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.25;
margin-bottom: 0.5rem;
color: var(--foreground);
}
.prose-ebook .chapter-lead {
font-size: 0.9375rem;
color: var(--muted);
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.prose-ebook p {
color: var(--prose);
margin-bottom: 1.25rem;
}
.prose-ebook p:last-child {
margin-bottom: 0;
}
.prose-ebook strong {
color: var(--foreground);
font-weight: 600;
}
.prose-ebook ul {
margin: 1rem 0 1.35rem;
padding-left: 1.35rem;
list-style: disc;
}
.prose-ebook li {
color: var(--prose);
margin-bottom: 0.55rem;
}
.prose-ebook ol {
margin: 1rem 0 1.35rem;
padding-left: 1.35rem;
list-style: decimal;
}
.prose-ebook blockquote {
margin: 1.5rem 0;
padding: 1rem 1.15rem 1rem 1.25rem;
border-left: 3px solid var(--gradient-1);
border-radius: 0 0.75rem 0.75rem 0;
background: var(--callout-bg);
border-top: 1px solid var(--callout-border);
border-right: 1px solid var(--callout-border);
border-bottom: 1px solid var(--callout-border);
font-size: 0.98rem;
color: var(--foreground);
font-style: normal;
}
.prose-ebook blockquote p {
margin: 0;
color: inherit;
}
/* TOC active state */
.toc-link--active {
background: color-mix(in srgb, var(--gradient-1) 12%, transparent);
color: var(--foreground);
font-weight: 600;
}
.toc-link--active .toc-index {
background: linear-gradient(135deg, var(--gradient-1), var(--gradient-3));
color: white;
}
@media (prefers-reduced-motion: reduce) {
.cover-float,
.marquee-track,
.mesh-bg::before,
.mesh-bg::after,
.mesh-orb {
animation: none;
}
.btn-primary:hover,
.btn-secondary:hover {
transform: none;
}
}

77
app/layout.tsx Normal file
View File

@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import { DM_Sans, Plus_Jakarta_Sans, Source_Serif_4 } from "next/font/google";
import "./globals.css";
import { brand, ebookMeta } from "@/content/ebook";
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta",
subsets: ["latin"],
weight: ["500", "600", "700", "800"],
});
const sourceSerif = Source_Serif_4({
variable: "--font-source-serif",
subsets: ["latin"],
weight: ["400", "500", "600"],
});
export const metadata: Metadata = {
title: `${ebookMeta.title} — Free Digital Nomad Ebook`,
description: ebookMeta.subtitle,
keywords: [
"digital nomad",
"remote work",
"work from anywhere",
"nomad visa",
"location independent",
],
openGraph: {
title: `${ebookMeta.title} — Digital Nomad Guide`,
description: ebookMeta.subtitle,
type: "website",
locale: "en_US",
},
};
const themeScript = `
(function () {
try {
var stored = localStorage.getItem('theme');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
} catch (e) {}
})();
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${dmSans.variable} ${plusJakarta.variable} ${sourceSerif.variable} h-full scroll-smooth antialiased`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-full font-sans">
<div className="mesh-bg" aria-hidden>
<div className="mesh-orb mesh-orb--sky" />
</div>
{children}
</body>
</html>
);
}

316
app/page.tsx Normal file
View File

@@ -0,0 +1,316 @@
import Link from "next/link";
import { BookCover } from "@/components/BookCover";
import { FaqAccordion } from "@/components/FaqAccordion";
import { HighlightIcon } from "@/components/HighlightIcon";
import { SiteHeader } from "@/components/SiteHeader";
import {
ebookMeta,
heroStats,
highlights,
nomadHubs,
pillars,
tableOfContents,
testimonial,
} from "@/content/ebook";
const hubMarquee = [...nomadHubs, ...nomadHubs];
export default function LandingPage() {
return (
<div className="relative min-h-screen">
<SiteHeader />
<main>
{/* Hero */}
<section className="relative overflow-hidden px-4 pb-14 pt-8 sm:px-6 sm:pb-20 sm:pt-12 lg:px-8 lg:pb-28 lg:pt-16">
<div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-2 lg:gap-20">
<div className="order-2 text-center lg:order-1 lg:text-left">
<p className="mb-4 inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface)] px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--accent)] backdrop-blur-md">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--gradient-1)] opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--gradient-1)]" />
</span>
Free ebook · Digital nomad · English
</p>
<h1 className="font-display text-[2.35rem] font-extrabold leading-[1.06] tracking-tight sm:text-5xl lg:text-[3.4rem]">
Work from{" "}
<span className="gradient-text">anywhere</span>
<span className="block text-[var(--foreground)]">with a plan.</span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-[var(--muted)] lg:mx-0">
<span className="font-semibold text-[var(--foreground)]">
{ebookMeta.title}
</span>{" "}
is a field guide for remote workers and founders building a
location-independent lifebases, visas, gear, community, and
rhythm.
</p>
<p className="mt-4 text-sm text-[var(--muted)]">
by{" "}
<span className="font-medium text-[var(--foreground)]">
{ebookMeta.author}
</span>
<span className="mx-2 opacity-40">·</span>
{ebookMeta.readTime}
<span className="mx-2 opacity-40">·</span>
{ebookMeta.pages} pages
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row lg:justify-start">
<Link href="/read" className="btn-primary w-full sm:w-auto">
Read free no signup
</Link>
<a href="#contents" className="btn-secondary w-full sm:w-auto">
Browse chapters
</a>
</div>
<dl className="mt-10 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:max-w-xl">
{heroStats.map((stat) => (
<div
key={stat.label}
className="rounded-xl border border-[var(--border)] bg-[var(--surface)]/60 px-3 py-3 text-center backdrop-blur-sm"
>
<dt className="font-display text-lg font-bold text-[var(--foreground)]">
{stat.value}
</dt>
<dd className="mt-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--muted)]">
{stat.label}
</dd>
</div>
))}
</dl>
</div>
<div className="order-1 flex justify-center lg:order-2 lg:justify-end">
<BookCover />
</div>
</div>
</section>
{/* Hub marquee */}
<section
className="border-y border-[var(--border)] bg-[var(--surface-solid)]/40 py-4"
aria-label="Popular nomad hubs"
>
<div className="overflow-hidden">
<div className="marquee-track gap-3 px-2">
{hubMarquee.map((hub, i) => (
<span
key={`${hub.city}-${i}`}
className="mx-1.5 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-sm backdrop-blur-sm"
>
<span className="font-semibold text-[var(--foreground)]">
{hub.city}
</span>
<span className="text-[var(--muted)]">{hub.country}</span>
<span className="hidden text-xs text-[var(--accent)] sm:inline">
· {hub.vibe}
</span>
</span>
))}
</div>
</div>
</section>
{/* Highlights */}
<section className="px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
<div className="mx-auto max-w-6xl">
<p className="text-center text-xs font-semibold uppercase tracking-[0.2em] text-[var(--accent)]">
What you&apos;ll master
</p>
<h2 className="mt-2 text-center font-serif text-2xl font-medium tracking-tight sm:text-3xl">
Built for the{" "}
<span className="gradient-text">digital nomad</span> reality
</h2>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{highlights.map((item) => (
<div
key={item.title}
className="glass-card group rounded-2xl p-6 transition-transform duration-300 hover:-translate-y-1"
>
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--gradient-1)]/15 via-[var(--gradient-2)]/15 to-[var(--gradient-3)]/15 text-[var(--accent)]">
<HighlightIcon name={item.icon} />
</div>
<h3 className="font-display text-base font-bold text-[var(--foreground)]">
{item.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
{item.description}
</p>
</div>
))}
</div>
</div>
</section>
{/* Pillars */}
<section className="border-y border-[var(--border)] bg-[var(--surface-solid)]/50 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-2xl text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--accent)]">
Four pillars
</p>
<h2 className="mt-2 font-serif text-2xl font-medium sm:text-3xl">
Location freedom,{" "}
<span className="gradient-text">without chaos</span>
</h2>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{pillars.map((pillar, index) => (
<article
key={pillar.title}
className="glass-card rounded-2xl p-6 sm:p-8"
>
<span className="font-mono text-xs font-bold text-[var(--accent)]">
0{index + 1}
</span>
<h3 className="mt-2 font-display text-lg font-bold">
{pillar.title}
</h3>
<p className="mt-2 text-[var(--muted)] leading-relaxed">
{pillar.body}
</p>
</article>
))}
</div>
</div>
</section>
{/* Testimonial */}
<section className="px-4 py-16 sm:px-6 lg:px-8">
<figure className="glass-card relative mx-auto max-w-3xl overflow-hidden rounded-3xl p-8 sm:p-10">
<div
className="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-[var(--gradient-1)]/20 blur-2xl"
aria-hidden
/>
<blockquote className="relative font-serif text-xl leading-relaxed text-[var(--foreground)] sm:text-2xl">
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
<figcaption className="relative mt-6 flex flex-wrap items-center gap-3 text-sm">
<span className="font-display font-bold">{testimonial.author}</span>
<span className="text-[var(--muted)]">{testimonial.role}</span>
<span className="w-full text-xs text-[var(--accent)] sm:w-auto">
{testimonial.location}
</span>
</figcaption>
</figure>
</section>
{/* Table of contents */}
<section
id="contents"
className="border-t border-[var(--border)] px-4 py-16 sm:px-6 sm:py-24 lg:px-8"
>
<div className="mx-auto max-w-6xl">
<div className="mb-10 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--accent)]">
Table of contents
</p>
<h2 className="mt-2 font-serif text-2xl font-medium sm:text-3xl">
Six chapters + introduction
</h2>
</div>
<p className="max-w-sm text-sm text-[var(--muted)]">
Read in-browser on laptop, tablet, or phonewith light/dark mode
for late-night flights.
</p>
</div>
<ol className="grid gap-3 lg:grid-cols-2">
{tableOfContents.map((item, index) => (
<li key={item.id}>
<Link
href={`/read#${item.id}`}
className="glass-card group flex gap-4 rounded-2xl p-5 transition-all hover:border-[var(--accent)]/35 hover:shadow-[var(--glow-sm)]"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--gradient-1)] to-[var(--gradient-3)] text-sm font-bold text-white">
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1">
<span className="block font-display font-semibold leading-snug group-hover:text-[var(--accent)]">
{item.title}
</span>
{item.summary ? (
<span className="mt-1 block text-sm text-[var(--muted)] line-clamp-2">
{item.summary}
</span>
) : null}
</span>
<span
className="shrink-0 self-center text-[var(--muted)] transition-transform group-hover:translate-x-1 group-hover:text-[var(--accent)]"
aria-hidden
>
</span>
</Link>
</li>
))}
</ol>
</div>
</section>
{/* FAQ */}
<section className="border-t border-[var(--border)] bg-[var(--surface-solid)]/40 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="mx-auto max-w-3xl">
<p className="text-center text-xs font-semibold uppercase tracking-[0.2em] text-[var(--accent)]">
FAQ
</p>
<h2 className="mt-2 text-center font-serif text-2xl font-medium sm:text-3xl">
Before you pack
</h2>
<div className="mt-10">
<FaqAccordion />
</div>
</div>
</section>
{/* CTA */}
<section className="px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="relative mx-auto max-w-6xl overflow-hidden rounded-3xl">
<div
className="absolute inset-0 bg-gradient-to-br from-teal-700 via-teal-600 to-sky-800 dark:from-teal-900 dark:via-teal-800 dark:to-slate-900"
aria-hidden
/>
<div
className="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg width=%2260%22 height=%2260%22 viewBox=%220 0 60 60%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cg fill=%22none%22 fill-rule=%22evenodd%22%3E%3Cg fill=%22%23ffffff%22 fill-opacity=%220.06%22%3E%3Cpath d=%22M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z%22/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-80"
aria-hidden
/>
<div className="relative px-8 py-14 text-center sm:px-16 sm:py-20">
<p className="text-xs font-bold uppercase tracking-[0.2em] text-teal-200">
Your next base starts here
</p>
<h2 className="mt-3 font-display text-2xl font-bold text-white sm:text-4xl">
Read the nomad playbook tonight
</h2>
<p className="mx-auto mt-4 max-w-lg text-base text-teal-50/90 sm:text-lg">
No signup. No paywall. Open on any device, switch themes for red-eye
flights, and jump straight to visas or community.
</p>
<Link
href="/read"
className="mt-8 inline-flex h-12 items-center justify-center rounded-full bg-white px-10 text-sm font-bold text-teal-800 shadow-xl transition-transform hover:scale-[1.02] active:scale-[0.98]"
>
Open {ebookMeta.title}
</Link>
</div>
</div>
</section>
</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-4 py-8 text-sm text-[var(--muted)] sm:flex-row sm:px-6 lg:px-8">
<p>
© {ebookMeta.year} {ebookMeta.author} · {ebookMeta.title} digital
nomad field guide
</p>
<Link
href="/read"
className="font-medium text-[var(--accent)] transition-opacity hover:opacity-80"
>
Read the ebook
</Link>
</div>
</footer>
</div>
);
}

84
app/read/page.tsx Normal file
View File

@@ -0,0 +1,84 @@
import Link from "next/link";
import { ChapterContent } from "@/components/ChapterContent";
import { ChapterNav } from "@/components/ChapterNav";
import { ReadSidebar } from "@/components/ReadSidebar";
import { ReadingProgress } from "@/components/ReadingProgress";
import { SiteHeader } from "@/components/SiteHeader";
import { chapters, ebookMeta } from "@/content/ebook";
export const metadata = {
title: `Read — ${ebookMeta.title} | Digital Nomad Guide`,
description: ebookMeta.subtitle,
};
export default function ReadPage() {
return (
<div className="relative min-h-screen">
<SiteHeader ctaLabel="Home" ctaHref="/" showBack />
<ReadingProgress />
<div className="mx-auto flex max-w-6xl gap-10 px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
<ReadSidebar />
<div className="min-w-0 flex-1" style={{ maxWidth: "var(--read-max)" }}>
<header className="mb-10 border-b border-[var(--border)] pb-10 text-center sm:mb-12 sm:pb-12">
<p className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface)] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-[var(--accent)]">
Digital nomad · English
</p>
<h1 className="mt-4 font-serif text-3xl font-medium tracking-tight sm:text-4xl">
<span className="gradient-text">{ebookMeta.title}</span>
</h1>
<p className="mx-auto mt-3 max-w-lg text-[var(--muted)] leading-relaxed">
{ebookMeta.subtitle}
</p>
<p className="mt-3 text-sm text-[var(--muted)]">
{ebookMeta.author} · {ebookMeta.readTime} · {ebookMeta.pages} pages
</p>
<Link href="/" className="btn-secondary mt-6 text-sm lg:hidden">
Landing page
</Link>
</header>
<article className="prose-ebook">
{chapters.map((chapter, index) => (
<section
key={chapter.id}
id={chapter.id}
className={
index > 0
? "mt-16 scroll-mt-[calc(var(--header-h)+1.5rem)] border-t border-[var(--border)] pt-16 sm:mt-20 sm:pt-20"
: "scroll-mt-[calc(var(--header-h)+1.5rem)]"
}
>
<h2>{chapter.title}</h2>
{chapter.summary ? (
<p className="chapter-lead">{chapter.summary}</p>
) : null}
<ChapterContent content={chapter.content} />
<ChapterNav chapterId={chapter.id} />
</section>
))}
</article>
<footer className="mt-16 rounded-2xl border border-[var(--border)] bg-[var(--surface)] px-6 py-10 text-center backdrop-blur-md sm:mt-20">
<p className="font-display text-lg font-bold text-[var(--foreground)]">
You&apos;ve finished the trailhead
</p>
<p className="mt-2 text-sm text-[var(--muted)]">
Pick one habit from this guide and test it in your next base. Safe
travelsand deep work.
</p>
<div className="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href="/" className="btn-secondary">
Back to home
</Link>
<a href="#introduction" className="btn-primary">
Re-read intro
</a>
</div>
</footer>
</div>
</div>
</div>
);
}

78
components/BookCover.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { ebookMeta } from "@/content/ebook";
export function BookCover({ className = "" }: { className?: string }) {
return (
<div
className={`cover-float relative w-full max-w-[min(100%,300px)] sm:max-w-[320px] ${className}`}
>
<div
className="absolute -inset-6 rounded-[2rem] bg-gradient-to-br from-teal-500/50 via-amber-400/40 to-sky-500/50 opacity-60 blur-2xl"
aria-hidden
/>
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/25">
<div
className="absolute inset-0 bg-gradient-to-br from-teal-800 via-teal-700 to-sky-900"
aria-hidden
/>
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent, transparent 23px, rgba(255,255,255,0.06) 24px)",
}}
aria-hidden
/>
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_30%_12%,rgba(255,255,255,0.22),transparent_55%)]"
aria-hidden
/>
<div
className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-t from-black/50 to-transparent"
aria-hidden
/>
<div
className="absolute left-0 top-0 h-full w-4 bg-gradient-to-r from-black/35 to-transparent"
aria-hidden
/>
<div className="absolute right-5 top-6 opacity-20" aria-hidden>
<GlobeMark className="h-24 w-24 text-white" />
</div>
<div className="relative flex h-full flex-col justify-between p-7 text-white sm:p-8">
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] font-bold uppercase tracking-[0.28em] text-teal-200/90">
Digital nomad
</p>
<span className="rounded-full border border-white/20 bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/80">
EN
</span>
</div>
<div>
<h2 className="font-serif text-[1.7rem] font-medium leading-[1.1] tracking-tight sm:text-[1.9rem]">
{ebookMeta.title}
</h2>
<p className="mt-3 text-xs leading-relaxed text-teal-100/85 sm:text-sm">
{ebookMeta.subtitle}
</p>
</div>
<div>
<p className="font-mono text-[10px] tracking-wider text-white/50">
48.8566° N · Work from anywhere
</p>
<p className="mt-2 text-xs font-medium text-white/70">
{ebookMeta.author}
</p>
</div>
</div>
</div>
</div>
);
}
function GlobeMark({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.75">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
);
}

View File

@@ -0,0 +1,64 @@
function parseInline(text: string) {
const parts = text.split(/(\*\*[^*]+\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) {
return (
<strong key={i}>{part.slice(2, -2)}</strong>
);
}
return part;
});
}
export function ChapterContent({ content }: { content: string }) {
const blocks = content.trim().split(/\n\n+/);
return (
<>
{blocks.map((block, index) => {
const trimmed = block.trim();
if (trimmed.startsWith("> ")) {
const quote = trimmed.replace(/^>\s?/gm, "").trim();
return (
<blockquote key={index}>
<p>{parseInline(quote)}</p>
</blockquote>
);
}
if (trimmed.startsWith("**") && trimmed.endsWith("**") && !trimmed.includes("\n")) {
return (
<p key={index} className="!mb-4 font-medium text-[var(--foreground)]">
{parseInline(trimmed)}
</p>
);
}
if (trimmed.startsWith("- ")) {
const items = trimmed.split("\n").map((line) => line.replace(/^- /, ""));
return (
<ul key={index}>
{items.map((item, i) => (
<li key={i}>{parseInline(item)}</li>
))}
</ul>
);
}
if (/^\d+\.\s/.test(trimmed)) {
const items = trimmed.split("\n").map((line) => line.replace(/^\d+\.\s/, ""));
return (
<ol key={index}>
{items.map((item, i) => (
<li key={i}>{parseInline(item)}</li>
))}
</ol>
);
}
return <p key={index}>{parseInline(trimmed)}</p>;
})}
</>
);
}

50
components/ChapterNav.tsx Normal file
View File

@@ -0,0 +1,50 @@
import Link from "next/link";
import { chapters } from "@/content/ebook";
type ChapterNavProps = {
chapterId: string;
};
export function ChapterNav({ chapterId }: ChapterNavProps) {
const index = chapters.findIndex((c) => c.id === chapterId);
if (index < 0) return null;
const prev = index > 0 ? chapters[index - 1] : null;
const next = index < chapters.length - 1 ? chapters[index + 1] : null;
return (
<nav
className="mt-12 flex flex-col gap-3 border-t border-[var(--border)] pt-8 sm:flex-row sm:justify-between"
aria-label="Previous and next chapter"
>
{prev ? (
<Link
href={`#${prev.id}`}
className="group glass-card flex flex-1 flex-col rounded-2xl px-5 py-4 transition-shadow hover:shadow-[var(--glow-sm)]"
>
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Previous
</span>
<span className="mt-1 font-medium group-hover:text-[var(--accent)]">
{prev.title}
</span>
</Link>
) : (
<div className="hidden flex-1 sm:block" />
)}
{next ? (
<Link
href={`#${next.id}`}
className="group glass-card flex flex-1 flex-col rounded-2xl px-5 py-4 text-left sm:text-right transition-shadow hover:shadow-[var(--glow-sm)]"
>
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Next
</span>
<span className="mt-1 font-medium group-hover:text-[var(--accent)]">
{next.title}
</span>
</Link>
) : null}
</nav>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { faq } from "@/content/ebook";
export function FaqAccordion() {
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<div className="space-y-3">
{faq.map((item, index) => {
const isOpen = openIndex === index;
return (
<div key={item.question} className="glass-card overflow-hidden rounded-2xl">
<button
type="button"
id={`faq-btn-${index}`}
aria-expanded={isOpen}
aria-controls={`faq-panel-${index}`}
onClick={() => setOpenIndex(isOpen ? null : index)}
className="flex w-full items-start justify-between gap-4 px-5 py-4 text-left sm:px-6 sm:py-5"
>
<span className="font-display text-base font-semibold leading-snug sm:text-lg">
{item.question}
</span>
<span
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--gradient-1)]/10 text-[var(--accent)] transition-transform duration-200 ${
isOpen ? "rotate-45" : ""
}`}
aria-hidden
>
+
</span>
</button>
<div
id={`faq-panel-${index}`}
role="region"
aria-labelledby={`faq-btn-${index}`}
className={`grid transition-[grid-template-rows] duration-200 ${
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
}`}
>
<div className="overflow-hidden">
<p className="border-t border-[var(--border)] px-5 pb-5 pt-0 text-[var(--muted)] leading-relaxed sm:px-6 sm:pb-6">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,25 @@
const paths: Record<string, string> = {
globe:
"M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v20M2 12h20M4.5 7.5h15M4.5 16.5h15",
map: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7",
document:
"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8",
compass:
"M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83M12 8a4 4 0 100 8 4 4 0 000-8z",
};
export function HighlightIcon({ name }: { name: string }) {
const d = paths[name] ?? paths.globe;
return (
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden
>
<path d={d} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

161
components/ReadSidebar.tsx Normal file
View File

@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { tableOfContents } from "@/content/ebook";
export function ReadSidebar() {
const [activeId, setActiveId] = useState(tableOfContents[0]?.id ?? "");
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const sections = tableOfContents
.map((item) => document.getElementById(item.id))
.filter(Boolean) as HTMLElement[];
if (sections.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]?.target.id) {
setActiveId(visible[0].target.id);
}
},
{ rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] }
);
sections.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!mobileOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [mobileOpen]);
const tocList = (
<ul className="space-y-0.5">
{tableOfContents.map((item, index) => {
const isActive = activeId === item.id;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
onClick={() => setMobileOpen(false)}
className={`toc-link group flex gap-3 rounded-xl px-3 py-2.5 text-sm transition-colors ${
isActive
? "toc-link--active"
: "text-[var(--muted)] hover:bg-[var(--gradient-1)]/5 hover:text-[var(--foreground)]"
}`}
>
<span
className={`toc-index flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
isActive
? ""
: "bg-[var(--gradient-1)]/15 text-[var(--accent)]"
}`}
>
{index + 1}
</span>
<span className="min-w-0 leading-snug">
<span className="block font-medium">{item.title}</span>
{item.summary ? (
<span className="mt-0.5 block text-xs font-normal opacity-80 line-clamp-2">
{item.summary}
</span>
) : null}
</span>
</a>
</li>
);
})}
</ul>
);
return (
<>
<aside className="hidden w-64 shrink-0 lg:block">
<nav
className="glass-card sticky top-[calc(var(--header-h)+1.25rem)] max-h-[calc(100vh-var(--header-h)-2.5rem)] overflow-y-auto rounded-2xl p-5"
aria-label="Chapter navigation"
>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--accent)]">
Chapters
</p>
<div className="mt-4">{tocList}</div>
</nav>
</aside>
<div className="fixed bottom-5 right-4 z-40 lg:hidden">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="flex h-12 items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface-solid)] px-4 text-sm font-semibold text-[var(--foreground)] shadow-lg"
aria-expanded={mobileOpen}
aria-controls="mobile-toc-panel"
>
<ListIcon />
Chapters
</button>
</div>
{mobileOpen ? (
<div
className="fixed inset-0 z-50 lg:hidden"
role="dialog"
aria-modal="true"
aria-labelledby="mobile-toc-title"
>
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
aria-label="Close chapter menu"
onClick={() => setMobileOpen(false)}
/>
<div
id="mobile-toc-panel"
className="absolute bottom-0 left-0 right-0 max-h-[80vh] overflow-hidden rounded-t-3xl border border-[var(--border)] bg-[var(--surface-solid)] shadow-2xl"
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-4">
<p
id="mobile-toc-title"
className="font-display text-sm font-bold"
>
Jump to chapter
</p>
<button
type="button"
onClick={() => setMobileOpen(false)}
className="rounded-full px-3 py-1 text-sm text-[var(--muted)] hover:text-[var(--foreground)]"
>
Close
</button>
</div>
<div className="overflow-y-auto px-3 py-4 pb-8">{tocList}</div>
</div>
</div>
) : null}
</>
);
}
function ListIcon() {
return (
<svg
className="h-4 w-4 text-[var(--accent)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" strokeLinecap="round" />
</svg>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { useEffect, useState } from "react";
export function ReadingProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function onScroll() {
const doc = document.documentElement;
const scrollTop = doc.scrollTop;
const scrollHeight = doc.scrollHeight - doc.clientHeight;
const pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
setProgress(Math.min(100, Math.max(0, pct)));
}
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div
className="reading-progress"
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Reading progress"
>
<div
className="reading-progress__bar"
style={{ width: `${progress}%` }}
/>
</div>
);
}

62
components/SiteHeader.tsx Normal file
View File

@@ -0,0 +1,62 @@
import Link from "next/link";
import { ThemeToggle } from "@/components/ThemeToggle";
import { brand } from "@/content/ebook";
type SiteHeaderProps = {
ctaHref?: string;
ctaLabel?: string;
showBack?: boolean;
};
export function SiteHeader({
ctaHref = "/read",
ctaLabel = "Start reading",
showBack = false,
}: SiteHeaderProps) {
return (
<header className="sticky top-0 z-50 h-[var(--header-h)] border-b border-[var(--border)]/70 bg-[var(--background)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
{showBack ? (
<Link
href="/"
className="flex shrink-0 items-center gap-1.5 text-sm font-medium text-[var(--muted)] transition-colors hover:text-[var(--foreground)] sm:hidden"
>
<span aria-hidden></span> Home
</Link>
) : null}
<Link
href="/"
className={`group flex min-w-0 items-center gap-2.5 ${showBack ? "hidden sm:flex" : "flex"}`}
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--gradient-1)] via-[var(--gradient-2)] to-[var(--gradient-3)] text-white shadow-lg shadow-teal-500/20"
aria-hidden
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="9" />
<path d="M2 12h20M12 2c2.5 2.8 4 6 4 10s-1.5 7.2-4 10M12 2c-2.5 2.8-4 6-4 10s1.5 7.2 4 10" strokeLinecap="round" />
</svg>
</span>
<span className="min-w-0">
<span className="block truncate font-display text-sm font-bold tracking-tight text-[var(--foreground)] sm:text-base">
{brand.name}
</span>
<span className="hidden truncate text-[10px] font-medium uppercase tracking-wider text-[var(--muted)] sm:block">
{brand.tagline}
</span>
</span>
</Link>
<div className="flex items-center gap-2 sm:gap-3">
<ThemeToggle />
<Link
href={ctaHref}
className="hidden h-10 items-center justify-center rounded-full bg-gradient-to-r from-[var(--gradient-1)] via-[var(--gradient-2)] to-[var(--gradient-3)] px-5 text-sm font-semibold text-white shadow-md shadow-teal-500/20 transition-transform hover:scale-[1.02] active:scale-[0.98] sm:inline-flex"
>
{ctaLabel}
</Link>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
type Theme = "light" | "dark";
function getPreferredTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setTheme(getPreferredTheme());
setMounted(true);
}, []);
function toggle() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
applyTheme(next);
}
return (
<button
type="button"
onClick={toggle}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="theme-toggle group relative flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface)]/80 text-[var(--foreground)] shadow-sm backdrop-blur-md transition-all hover:border-[var(--accent)]/40 hover:shadow-[var(--glow-sm)]"
>
<span className="sr-only">
{mounted
? theme === "dark"
? "Light mode"
: "Dark mode"
: "Toggle theme"}
</span>
<SunIcon
className={`absolute h-[18px] w-[18px] transition-all duration-300 ${
mounted && theme === "dark"
? "rotate-0 scale-100 opacity-100"
: "rotate-90 scale-0 opacity-0"
}`}
/>
<MoonIcon
className={`absolute h-[18px] w-[18px] transition-all duration-300 ${
mounted && theme !== "dark"
? "rotate-0 scale-100 opacity-100"
: "-rotate-90 scale-0 opacity-0"
}`}
/>
</button>
);
}
function SunIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
function MoonIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden
>
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3 7 7 0 1 0 21 14.5z" />
</svg>
);
}

276
content/ebook.ts Normal file
View File

@@ -0,0 +1,276 @@
export const brand = {
name: "Nomad Roam",
tagline: "Work anywhere. Live deliberately.",
logoLetter: "N",
};
export const ebookMeta = {
title: "Nomad Roam",
subtitle:
"The Practical Guide to Remote Work, Global Bases, and Life on the Move",
author: "Maya Chen",
pages: 52,
readTime: "40 min",
year: 2026,
};
export const heroStats = [
{ value: "6", label: "Core chapters" },
{ value: "40 min", label: "Total read" },
{ value: "0", label: "Paywalls" },
{ value: "100%", label: "Browser-based" },
];
export const highlights = [
{
title: "Remote-first frameworks",
description:
"Income, contracts, and async workflows built for people who change cities—not desks.",
icon: "globe",
},
{
title: "Base selection playbook",
description:
"Compare hubs by cost, timezone overlap, visa friction, and community—without spreadsheet chaos.",
icon: "map",
},
{
title: "Legal & logistics clarity",
description:
"Visas, taxes, insurance, and paperwork explained in plain English—not lawyer fog.",
icon: "document",
},
{
title: "Field-tested nomad rhythm",
description:
"Avoid burnout loops: arrival weeks, deep-work blocks, and recovery when Wi-Fi is your office.",
icon: "compass",
},
];
export const pillars = [
{
title: "Work that travels",
body: "Design income and deliverables so your laptop—not your lease—defines where you belong.",
},
{
title: "Bases, not burnout",
body: "Stay long enough to learn a place, short enough to stay curious. Rhythm beats constant motion.",
},
{
title: "Paper trail confidence",
body: "Know which visa, tax, and insurance questions matter before you book a one-way ticket.",
},
{
title: "People in the loop",
body: "Coworking, coliving, and intentional community—without replacing home with hostel noise.",
},
];
export const nomadHubs = [
{ city: "Lisbon", country: "Portugal", vibe: "EU hub · mild winters" },
{ city: "Chiang Mai", country: "Thailand", vibe: "Low cost · creator scene" },
{ city: "Mexico City", country: "Mexico", vibe: "Culture · US time zones" },
{ city: "Bali", country: "Indonesia", vibe: "Wellness · surf mornings" },
{ city: "Berlin", country: "Germany", vibe: "Nightlife · startup energy" },
{ city: "Medellín", country: "Colombia", vibe: "Spring weather · value" },
{ city: "Tbilisi", country: "Georgia", vibe: "Visa ease · mountain weekends" },
{ city: "Da Nang", country: "Vietnam", vibe: "Beach · focused deep work" },
];
export const testimonial = {
quote:
"I stopped treating every city like a vacation and every month like a crisis. This guide gave me a checklist I actually use before I book flights.",
author: "Jordan Ellis",
role: "Product designer · 3 years nomadic",
location: "Currently based in Lisbon",
};
export const faq = [
{
question: "Who is this ebook for?",
answer:
"Remote employees, freelancers, founders, and career switchers planning—or living—the digital nomad lifestyle. You do not need prior travel experience; you do need curiosity and a willingness to plan.",
},
{
question: "Is legal and tax advice country-specific?",
answer:
"We explain categories and decision trees (residency triggers, tourist vs digital-nomad visas, contractor vs employee) so you know what to verify with a qualified professional in your situation. This is education, not legal counsel.",
},
{
question: "Do I need special apps or paid tools?",
answer:
"No. Frameworks use notebooks, calendars, and free research habits. Optional tool mentions are suggestions, not requirements.",
},
{
question: "Can I read offline?",
answer:
"The site works in any modern browser. For offline reading, use your browsers “Save page” or print-to-PDF from the read view before a flight.",
},
{
question: "How often is content updated?",
answer:
"Hub notes and visa landscapes change fast. We revise examples seasonally; always confirm official government sources before you travel.",
},
];
export const chapters = [
{
id: "introduction",
title: "Introduction — The Nomad Path",
summary: "Why location independence is a design problem—not a personality type.",
content: `You do not become a digital nomad by buying a one-way ticket. You become one by aligning three forces: **work that can move**, **money that survives borders**, and **a nervous system that can reset in new places**.
The romantic image—laptop on a beach, sunset, zero meetings—is marketing. The lived version is messier: time zones that eat your sleep, visas that expire quietly, friendships that pause when you fly, and the strange guilt of feeling lonely in beautiful cities.
This ebook is a field guide, not a fantasy brochure. You will get checklists, decision trees, and short exercises you can finish between coworking sessions. No guru voice. No “quit your job tomorrow” bait.
**What you will build:**
- A clear nomad stack: income, role, constraints
- A base-selection method you can reuse every move
- A paperwork map so surprises shrink
- A weekly rhythm that survives airports
Pack light mentally. Start with one chapter that matches your bottleneck—visa anxiety, income instability, or social drift—and ship one change this week.`,
},
{
id: "chapter-1",
title: "Chapter 1 — Your Nomad Stack",
summary: "Income, skills, and boundaries before you choose a destination.",
content: `Destinations are downstream of your stack. The stack answers: **How do you get paid, from whom, in which currency, with what internet and legal exposure?**
**Common stacks:**
1. **Employed remote** — W-2 or local contract; employer may restrict countries
2. **Freelance / agency** — Clients across time zones; you own paperwork
3. **Product or content** — Async revenue; slower ramp, higher upside
4. **Hybrid** — Job + side project; watch tax and conflict-of-interest rules
**The 90-minute audit:** Write four lines: monthly nut (real number), minimum viable income, hours you can sell per week, and countries you must avoid for compliance. If income is volatile, add a three-month runway target before any move.
**Boundaries that save you:**
- Async default: batch messages; protect two deep blocks weekly
- Client geography: note who expects live calls and when
- “No-go” countries from employer policy or banking friction
> Field note: A cheaper city cannot fix a broken offer. Strengthen the stack first; then optimize geography.`,
},
{
id: "chapter-2",
title: "Chapter 2 — Choosing Your Next Base",
summary: "Hubs, seasons, cost, timezone fit, and how long to stay.",
content: `A base is not a vacation spot. It is infrastructure: Wi-Fi density, coworking culture, healthcare access, visa path, and humans who understand remote work.
**Score each candidate 15 on:**
- **Timezone fit** with clients or team
- **Cost vs runway** (rent + coworking + food, 30-day reality)
- **Visa friction** (days allowed, extensions, digital-nomad programs)
- **Community** (events, coliving, English comfort if needed)
- **Season** (heat, rain, smoke, holidays that shut cities down)
Stay **48 weeks** minimum for medium bases; shorter stays multiply logistics without depth. Longer stays may trigger tax residency questions—track days.
**Arrival week ritual:**
- Day 12: housing check, SIM, grocery route
- Day 34: coworking trial passes, gym or walk loop
- Day 57: one social anchor (meetup, coliving dinner, sport)
> Field note: The best hub for year one may be boring on Instagram. Boring plus reliable beats photogenic plus fragile Wi-Fi.`,
},
{
id: "chapter-3",
title: "Chapter 3 — Visas, Taxes, and the Paper Trail",
summary: "Categories, not shortcuts—what to verify before you land.",
content: `Laws lag lifestyle. Your job is not to hack borders; it is to **know which category you are in** and document decisions.
**Visa families (simplified):**
- **Tourist** — Short stay; usually no local work permission
- **Digital nomad / remote worker** — Longer stay; income from abroad; specific insurance proofs
- **Business / freelance local** — May require entity or sponsor
- **Residency pathways** — Triggered by days, ties, or intent—research early
**Tax thinking (education only):**
- Where are you tax resident today?
- Will days in a country create filing duties?
- Do you invoice as individual, LLC, or through an employer?
- Are social contributions required abroad?
Keep a **travel ledger**: dates in/out, purpose, accommodation, key receipts. Pair it with a folder: passport scans, insurance, contracts, invitation letters.
**Before you fly checklist:**
- Entry rules printed (government source, dated)
- Return/onward ticket if required
- Proof of funds or insurance if required
- Employer letter if employed remote
Consult a cross-border tax professional when stacks mix countries, equity, or high income.`,
},
{
id: "chapter-4",
title: "Chapter 4 — Gear, Wi-Fi, and the Mobile Office",
summary: "Reliable connectivity and a carry-on office that does not break your back.",
content: `Your office is a system: power, network, audio, backup, and ergonomics that fit Airbnb desks.
**Connectivity hierarchy:**
1. Apartment or coliving Wi-Fi—test on arrival with video call
2. Phone hotspot with local eSIM as failover
3. Coworking day pass for upload-heavy days
4. Café last—assume noise and flaky upload
**Core kit (adjust to your work):**
- Laptop you can repair or replace quickly
- Compact hub, USB-C charger, universal adapter
- Noise-canceling headphones + backup earbuds
- External mic if you lead calls
- Cloud backups automated daily
**Health on the road:** External keyboard when possible; wrist stretch breaks; 2448h screen curfew after long flights. Sun and walkable neighborhoods are productivity tools.
**Exercise:** Pack list audit—remove anything you have not used on last two trips. Redistribute weight; digitize paper.`,
},
{
id: "chapter-5",
title: "Chapter 5 — Community Without Burnout",
summary: "Belonging, loneliness, and social design on the move.",
content: `Nomads do not lack people—they lack **continuity**. Every city offers meetups; few offer relationships that survive your next flight.
**Social design principles:**
- **Repeat weekly anchors** over one-off events (climb crew, run club, coworking lunch)
- **Depth over breadth**—two friends beat twenty hellos
- **Async ties**—voice notes and standing calls with home circles
- **Honest pacing**—arrival weeks need rest, not networking marathons
Coliving can accelerate belonging; it can also amplify drama. Choose houses with clear work hours and shared norms.
When loneliness hits, distinguish **environmental** (need routine, sunlight, movement) from **relational** (need repair conversations, not another party). Both are valid; fixes differ.
> Field note: Community is a system you maintain, not a city you discover.`,
},
{
id: "chapter-6",
title: "Chapter 6 — The Sustainable Nomad Rhythm",
summary: "Close loops, protect deep work, and move with intention.",
content: `Sustainability beats streaks. The goal is years, not months of heroics.
**Weekly rhythm template:**
- **Two deep blocks** (90120 min) for creation or learning
- **One admin block** for visas, finance, inbox
- **One body block**—train, hike, swim
- **One open evening**—unplanned social or rest
- **One weekly review**—runway, calendar, relationships
**Move decisions:** Leave when you can name three frictions that logistics cannot fix (visa clock, health, work fit, community). Stay when one more month would compound language, clients, or friendships.
**Shutdown ritual (10 minutes):**
- Capture loose tasks and next base research
- Confirm calendar timezone labels
- Message one person you appreciate
You are not escaping life—you are distributing it across places you choose with eyes open. Return to this guide when the noise returns. Adjust one habit. Book one call. Walk one block. That is enough for today.`,
},
];
export const tableOfContents = chapters.map((c) => ({
id: c.id,
title: c.title,
summary: c.summary,
}));

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

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

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
},
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
}

4035
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}