kaboom
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.pnp
|
||||
.pnp.js
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
**/.gitkeep
|
||||
.cache
|
||||
/.astro/
|
||||
/.direnv/
|
||||
53
AGENTS.md
Normal file
53
AGENTS.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Portfolio Website — Linda Adel
|
||||
|
||||
Oil painting artist portfolio. Astro 6 SSG with Keystatic CMS.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Astro 6.4.8** — static output (`output: "static"`)
|
||||
- **Keystatic** — local git-backed CMS, loaded only in dev via `NODE_ENV=development`
|
||||
- **React** — only for Keystatic admin UI (`@astrojs/react`)
|
||||
- **TypeScript**, **plain CSS** (no Tailwind)
|
||||
- Pastel pink-purple palette (see `global.css` vars)
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `astro.config.mjs` | Astro config — react + conditional Keystatic integration |
|
||||
| `keystatic.config.ts` | CMS schema — paintings collection + about singleton |
|
||||
| `src/content.config.ts` | Custom loaders for both collections (reads `.yaml` directly) |
|
||||
| `src/site-config.ts` | All site strings: name, email, tagline, location, etc. |
|
||||
| `src/styles/global.css` | All styles — pastel palette, responsive breakpoints |
|
||||
|
||||
## Content Layer — Pure YAML, No Frontmatter
|
||||
|
||||
Both collections use **pure `.yaml` files** (no `---` frontmatter). Keystatic's `format: "yaml"` (the default) expects the entire file to be valid YAML. Astro uses custom loaders that parse YAML directly.
|
||||
|
||||
**Paintings** (`src/content/paintings/*.yaml`): `title`, `image`, `year`, `medium`, `dimensions`, `featured`, `description` — all top-level YAML keys. Custom `yamlGlobLoader` discovers `.yaml` files, parses each, stores by filename stem as entry ID.
|
||||
|
||||
**About** (`src/content/about/index.yaml`): `photo`, `bio` as top-level YAML keys. Custom `about-loader` reads the single file. Bio paragraphs split on `\n\n`.
|
||||
|
||||
## Why Custom Loaders
|
||||
|
||||
Keystatic's `format: "md"` passes entire files to js-yaml, which **throws on `---` frontmatter delimiters** ("expected a single document"). Using `format: "yaml"` avoids this, but Astro's built-in loaders don't support `.yaml` files. Custom loaders bridge the gap.
|
||||
|
||||
Shared `yamlGlobLoader(base, pattern, ctx)` function in `src/content.config.ts` handles discovery, parsing, and hot reload for any YAML-based collection.
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
```sh
|
||||
npm run dev # NODE_ENV=development astro dev
|
||||
npm run build # static build (Keystatic excluded)
|
||||
npm run preview # preview static build
|
||||
```
|
||||
|
||||
Keystatic admin at `/keystatic` — only available in dev mode.
|
||||
|
||||
## Important
|
||||
|
||||
- Restart dev server after changing `keystatic.config.ts` (Vite caches it). If things break, delete `node_modules/.vite/` and `.astro/`.
|
||||
- All content files are pure YAML — **no `---` frontmatter**. Don't add it.
|
||||
- Don't use Keystatic `fields.document()` — Astro's content layer doesn't support `.mdoc` extension.
|
||||
- All artist-facing strings in `src/site-config.ts` — edit there, not in templates.
|
||||
- Dev command: `NODE_ENV=development astro dev` — required for Keystatic integration to load.
|
||||
22
Makefile
Normal file
22
Makefile
Normal file
@@ -0,0 +1,22 @@
|
||||
.PHONY: dev build preview check lint clean install
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
build:
|
||||
npm run build
|
||||
|
||||
preview:
|
||||
npm run preview
|
||||
|
||||
check:
|
||||
npm run check
|
||||
|
||||
lint:
|
||||
npm run check
|
||||
|
||||
clean:
|
||||
rm -rf dist/ .astro/ node_modules/
|
||||
12
astro.config.mjs
Normal file
12
astro.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import react from "@astrojs/react";
|
||||
|
||||
export default defineConfig({
|
||||
output: "static",
|
||||
integrations: [
|
||||
react(),
|
||||
...(process.env.NODE_ENV === "development"
|
||||
? [(await import("@keystatic/astro")).default()]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
49
keystatic.config.ts
Normal file
49
keystatic.config.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { config, collection, fields, singleton } from "@keystatic/core";
|
||||
|
||||
export default config({
|
||||
storage: {
|
||||
kind: "local",
|
||||
},
|
||||
collections: {
|
||||
paintings: collection({
|
||||
label: "Paintings",
|
||||
slugField: "title",
|
||||
path: "src/content/paintings/*",
|
||||
schema: {
|
||||
title: fields.slug({ name: { label: "Title" } }),
|
||||
image: fields.image({
|
||||
label: "Image",
|
||||
description: "Photograph of the painting",
|
||||
}),
|
||||
year: fields.integer({ label: "Year" }),
|
||||
medium: fields.text({ label: "Medium" }),
|
||||
dimensions: fields.text({ label: "Dimensions" }),
|
||||
description: fields.text({
|
||||
label: "Description",
|
||||
multiline: true,
|
||||
}),
|
||||
featured: fields.checkbox({
|
||||
label: "Featured",
|
||||
defaultValue: false,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
singletons: {
|
||||
about: singleton({
|
||||
label: "About",
|
||||
path: "src/content/about/index",
|
||||
format: "yaml",
|
||||
schema: {
|
||||
photo: fields.image({
|
||||
label: "Photo",
|
||||
description: "Artist portrait",
|
||||
}),
|
||||
bio: fields.text({
|
||||
label: "Bio",
|
||||
multiline: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
9829
package-lock.json
generated
Normal file
9829
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "portfolio-website",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"packageManager": "npm@11.2.0",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"keystatic": "keystatic"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/react": "^5.0.7",
|
||||
"@keystatic/astro": "^5.0.4",
|
||||
"@keystatic/core": "^0.5.40",
|
||||
"astro": "^6.4.8",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3"
|
||||
}
|
||||
}
|
||||
6
public/favicon.svg
Normal file
6
public/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#f8f6f3" rx="2"/>
|
||||
<circle cx="16" cy="16" r="10" fill="#8b4513" opacity="0.3"/>
|
||||
<circle cx="16" cy="16" r="6" fill="#8b4513" opacity="0.5"/>
|
||||
<circle cx="16" cy="16" r="3" fill="#8b4513" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
9
public/images/artist-photo.svg
Normal file
9
public/images/artist-photo.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" fill="none">
|
||||
<rect width="300" height="300" fill="#E8E0D8" rx="150"/>
|
||||
<ellipse cx="150" cy="130" rx="55" ry="60" fill="#D0C0B0" opacity="0.6"/>
|
||||
<ellipse cx="150" cy="200" rx="80" ry="70" fill="#C0B0A0" opacity="0.5"/>
|
||||
<circle cx="135" cy="120" r="5" fill="#8B7B6B" opacity="0.4"/>
|
||||
<circle cx="165" cy="120" r="5" fill="#8B7B6B" opacity="0.4"/>
|
||||
<path d="M135 145 Q150 155 165 145" stroke="#9B8B7B" stroke-width="2" fill="none" opacity="0.5"/>
|
||||
<ellipse cx="150" cy="100" rx="50" ry="20" fill="#B0A090" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
19
public/images/coastal-breeze.svg
Normal file
19
public/images/coastal-breeze.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" fill="none">
|
||||
<rect width="600" height="800" fill="#D4E4E0"/>
|
||||
<rect x="0" y="0" width="600" height="320" fill="#E8EEF0"/>
|
||||
<ellipse cx="200" cy="80" rx="120" ry="40" fill="#F5F8FA" opacity="0.6"/>
|
||||
<rect x="0" y="320" width="600" height="480" fill="#4A7C8C"/>
|
||||
<rect x="0" y="320" width="600" height="460" fill="#3A6B7A" opacity="0.6"/>
|
||||
<rect x="0" y="340" width="600" height="440" fill="#2B5A6A" opacity="0.4"/>
|
||||
<rect x="0" y="380" width="600" height="400" fill="#1A4A5A" opacity="0.3"/>
|
||||
<ellipse cx="200" cy="330" rx="200" ry="30" fill="#B8D4D0" opacity="0.4"/>
|
||||
<rect x="0" y="600" width="600" height="200" fill="#5A7A5A" opacity="0.5"/>
|
||||
<rect x="0" y="620" width="600" height="180" fill="#4A6A4A" opacity="0.6"/>
|
||||
<path d="M0 640 Q50 620 100 635 Q150 650 200 630 Q250 615 300 640 Q350 660 400 635 Q450 615 500 640 Q550 660 600 635 L600 800 L0 800 Z" fill="#3A5A3A" opacity="0.4"/>
|
||||
<rect x="50" y="610" width="3" height="30" fill="#3A5A3A" opacity="0.5" rx="1"/>
|
||||
<rect x="120" y="615" width="2" height="25" fill="#4A6A4A" opacity="0.4" rx="1"/>
|
||||
<rect x="200" y="608" width="3" height="32" fill="#3A5A3A" opacity="0.5" rx="1"/>
|
||||
<rect x="350" y="612" width="2" height="28" fill="#4A6A4A" opacity="0.4" rx="1"/>
|
||||
<ellipse cx="450" cy="200" rx="60" ry="30" fill="#F5F8FA" opacity="0.3"/>
|
||||
<ellipse cx="450" cy="195" rx="40" ry="20" fill="#FAFCFF" opacity="0.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
public/images/golden-hour.svg
Normal file
13
public/images/golden-hour.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" fill="none">
|
||||
<rect width="600" height="800" fill="#F5F0EB"/>
|
||||
<ellipse cx="450" cy="200" rx="180" ry="120" fill="#E8A87C" opacity="0.8"/>
|
||||
<ellipse cx="460" cy="180" rx="140" ry="90" fill="#F4C7A0" opacity="0.6"/>
|
||||
<rect x="0" y="480" width="600" height="320" fill="#8B7355" opacity="0.3"/>
|
||||
<rect x="0" y="520" width="600" height="280" fill="#6B5340" opacity="0.4"/>
|
||||
<rect x="0" y="560" width="600" height="240" fill="#4A3B2F" opacity="0.5"/>
|
||||
<circle cx="480" cy="160" r="50" fill="#FFE0B2" opacity="0.5"/>
|
||||
<ellipse cx="480" cy="165" r="40" fill="#FFF3E0" opacity="0.4"/>
|
||||
<rect x="100" y="440" width="80" height="100" fill="#3D2B1F" opacity="0.6" rx="2"/>
|
||||
<rect x="200" y="420" width="60" height="120" fill="#3D2B1F" opacity="0.4" rx="2"/>
|
||||
<rect x="380" y="430" width="70" height="110" fill="#3D2B1F" opacity="0.5" rx="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 912 B |
15
public/images/morning-light.svg
Normal file
15
public/images/morning-light.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" fill="none">
|
||||
<rect width="600" height="800" fill="#E8E6E1"/>
|
||||
<rect x="0" y="0" width="240" height="800" fill="#C5C0B6" opacity="0.5"/>
|
||||
<rect x="0" y="0" width="20" height="800" fill="#A0998F" opacity="0.6"/>
|
||||
<rect x="240" y="0" width="360" height="800" fill="#F0EDE8"/>
|
||||
<polygon points="240,200 360,200 300,80" fill="#D0CBC0" opacity="0.4"/>
|
||||
<rect x="300" y="280" width="8" height="200" fill="#7A7062"/>
|
||||
<rect x="270" y="480" width="60" height="40" fill="#6B6358" rx="2"/>
|
||||
<rect x="280" y="490" width="40" height="100" fill="#5A5247" rx="1"/>
|
||||
<circle cx="310" cy="340" r="60" fill="#B8B0A4" opacity="0.15"/>
|
||||
<rect x="260" y="550" width="20" height="20" fill="#4A7C59" rx="2" opacity="0.8"/>
|
||||
<rect x="285" y="540" width="16" height="30" fill="#4A7C59" rx="2" opacity="0.6"/>
|
||||
<rect x="310" y="545" width="14" height="25" fill="#3A6B49" rx="2" opacity="0.7"/>
|
||||
<ellipse cx="340" cy="200" rx="120" ry="80" fill="#E8DCC8" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
12
shell.nix
Normal file
12
shell.nix
Normal file
@@ -0,0 +1,12 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_26
|
||||
gnumake
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export COREPACK_ENABLE_STRICT=0
|
||||
'';
|
||||
}
|
||||
9
src/components/Footer.astro
Normal file
9
src/components/Footer.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { SITE } from "../site-config";
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
© {new Date().getFullYear()} {SITE.name}. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
28
src/components/Header.astro
Normal file
28
src/components/Header.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import { SITE } from "../site-config";
|
||||
const currentPath = Astro.url.pathname;
|
||||
const links = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/gallery", label: "Gallery" },
|
||||
{ href: "/about", label: "About" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<div class="container header-inner">
|
||||
<div class="header-title">
|
||||
<a href="/">{SITE.name}</a>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
{links.map(({ href, label }) => (
|
||||
<a
|
||||
href={href}
|
||||
aria-current={currentPath === href ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
26
src/components/Hero.astro
Normal file
26
src/components/Hero.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import { SITE } from "../site-config";
|
||||
|
||||
export interface Props {
|
||||
image: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const { image, title, subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-image">
|
||||
<img src={image} alt={title} width="600" height="800" loading="eager" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<h1>{title}</h1>
|
||||
<p class="subtitle">{subtitle}</p>
|
||||
<p>
|
||||
{SITE.tagline} Based in {SITE.location}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
33
src/components/PaintingCard.astro
Normal file
33
src/components/PaintingCard.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
export interface Props {
|
||||
slug: string;
|
||||
title: string;
|
||||
image: string;
|
||||
year: number;
|
||||
medium: string;
|
||||
dimensions: string;
|
||||
}
|
||||
|
||||
const { slug, title, image, year, medium, dimensions } = Astro.props;
|
||||
---
|
||||
|
||||
<article class="painting-card fade-in">
|
||||
<a href={`/paintings/${slug}`}>
|
||||
<div class="painting-card-image">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
width="600"
|
||||
height="800"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="painting-card-body">
|
||||
<h2 class="painting-card-title">{title}</h2>
|
||||
<p class="painting-card-meta">
|
||||
{year} · {medium} · {dimensions}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
124
src/content.config.ts
Normal file
124
src/content.config.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
|
||||
async function parseYAML(content) {
|
||||
const { load } = await import("js-yaml");
|
||||
return load(content);
|
||||
}
|
||||
|
||||
async function yamlGlobLoader(base, pattern, { store, parseData, config, logger, watcher }) {
|
||||
const { glob: tinyglobby } = await import("tinyglobby");
|
||||
let baseDir = new URL(base, config.root);
|
||||
if (!baseDir.pathname.endsWith("/")) baseDir.pathname = `${baseDir.pathname}/`;
|
||||
const files = await tinyglobby(pattern, { cwd: fileURLToPath(baseDir) });
|
||||
const ids = new Set();
|
||||
for (const file of files) {
|
||||
const fileUrl = new URL(file, baseDir);
|
||||
const filePath = fileURLToPath(fileUrl);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
let data;
|
||||
try { data = await parseYAML(content); } catch (e) { logger.error(`Error parsing ${file}: ${e.message}`); continue; }
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) { logger.warn(`Skipping ${file}: not a YAML object`); continue; }
|
||||
const id = file.replace(/\.\w+$/, "");
|
||||
ids.add(id);
|
||||
try {
|
||||
const parsedData = await parseData({ id, data, filePath });
|
||||
store.set({ id, data: parsedData });
|
||||
} catch (e) { logger.error(`Schema validation error for ${file}: ${e.message}`); }
|
||||
}
|
||||
for (const existingId of store.keys()) {
|
||||
if (!ids.has(existingId)) store.delete(existingId);
|
||||
}
|
||||
if (watcher) {
|
||||
watcher.add(fileURLToPath(baseDir));
|
||||
const rescan = async () => {
|
||||
const fresh = await tinyglobby(pattern, { cwd: fileURLToPath(baseDir) });
|
||||
const freshIds = new Set();
|
||||
for (const file of fresh) {
|
||||
const filePath = fileURLToPath(new URL(file, baseDir));
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
try {
|
||||
const data = await parseYAML(content);
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const id = file.replace(/\.\w+$/, "");
|
||||
freshIds.add(id);
|
||||
const parsedData = await parseData({ id, data, filePath });
|
||||
store.set({ id, data: parsedData });
|
||||
}
|
||||
} catch (e) { logger.error(`Error reloading ${file}: ${e.message}`); }
|
||||
}
|
||||
for (const existingId of store.keys()) {
|
||||
if (!freshIds.has(existingId)) store.delete(existingId);
|
||||
}
|
||||
};
|
||||
watcher.on("change", rescan);
|
||||
watcher.on("add", rescan);
|
||||
watcher.on("unlink", rescan);
|
||||
}
|
||||
}
|
||||
|
||||
const paintings = defineCollection({
|
||||
loader: {
|
||||
name: "paintings-loader",
|
||||
load: (ctx) => yamlGlobLoader("src/content/paintings", "**/*.yaml", ctx),
|
||||
},
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
image: z.string(),
|
||||
year: z.number(),
|
||||
medium: z.string(),
|
||||
dimensions: z.string(),
|
||||
featured: z.boolean().default(false),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const about = defineCollection({
|
||||
loader: {
|
||||
name: "about-loader",
|
||||
load: async ({ store, parseData, config, logger, watcher }) => {
|
||||
const url = new URL("src/content/about/index.yaml", config.root);
|
||||
const filePath = fileURLToPath(url);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.warn("About file not found at src/content/about/index.yaml");
|
||||
return;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
let data;
|
||||
try { data = await parseYAML(content); } catch (e) {
|
||||
logger.error(`Error parsing about file: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
logger.error("About file must contain a YAML object");
|
||||
return;
|
||||
}
|
||||
const parsedData = await parseData({ id: "index", data });
|
||||
store.set({ id: "index", data: parsedData });
|
||||
if (watcher) {
|
||||
watcher.add(filePath);
|
||||
const onChange = async (changedPath) => {
|
||||
if (changedPath !== filePath) return;
|
||||
const newContent = fs.readFileSync(filePath, "utf-8");
|
||||
try {
|
||||
const newData = await parseYAML(newContent);
|
||||
if (newData && typeof newData === "object" && !Array.isArray(newData)) {
|
||||
const newParsedData = await parseData({ id: "index", data: newData });
|
||||
store.set({ id: "index", data: newParsedData });
|
||||
logger.info("Reloaded about data");
|
||||
}
|
||||
} catch (e) { logger.error(`Error reloading about file: ${e.message}`); }
|
||||
};
|
||||
watcher.on("change", onChange);
|
||||
watcher.on("add", onChange);
|
||||
}
|
||||
},
|
||||
},
|
||||
schema: z.object({
|
||||
photo: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { paintings, about };
|
||||
4
src/content/about/index.yaml
Normal file
4
src/content/about/index.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
bio: |-
|
||||
Your local awesome red head with the unique and messy style :3
|
||||
|
||||
She said she wanna fuck and I was like... "really? :3"
|
||||
11
src/content/paintings/coastal-breeze.yaml
Normal file
11
src/content/paintings/coastal-breeze.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
title: Coastal Breeze
|
||||
image: /images/coastal-breeze.svg
|
||||
year: 2023
|
||||
medium: Oil on panel
|
||||
dimensions: 18" x 24"
|
||||
description: >-
|
||||
A windswept coastal vista painted en plein air. Layers of sea green, teal, and
|
||||
cerulean build the restless ocean, while the sky shifts from pale blue to soft
|
||||
grey at the horizon. The foreground grasses are rendered with quick, gestural
|
||||
marks that echo the movement of the breeze.
|
||||
featured: true
|
||||
7
src/content/paintings/golden-hour.yaml
Normal file
7
src/content/paintings/golden-hour.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
title: Golden Hour
|
||||
image: /images/golden-hour.svg
|
||||
year: 2024
|
||||
medium: Oil on canvas
|
||||
dimensions: 30" x 40"
|
||||
description: best ever
|
||||
featured: false
|
||||
7
src/content/paintings/morning-light.yaml
Normal file
7
src/content/paintings/morning-light.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
title: "Morning Light"
|
||||
image: "/images/morning-light.svg"
|
||||
year: 2024
|
||||
medium: "Oil on linen"
|
||||
dimensions: "24\" x 30\""
|
||||
featured: false
|
||||
description: "Soft morning light filters through a window, casting cool blue shadows across a simple interior scene. The palette is restrained — pale greys, muted blues, and warm whites — punctuated by the deep green of a potted plant. Painted from life in a single sitting."
|
||||
27
src/layouts/BaseLayout.astro
Normal file
27
src/layouts/BaseLayout.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title = "Portfolio", description = "Oil paintings" } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
</head>
|
||||
<body>
|
||||
<slot name="header" />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<slot name="footer" />
|
||||
</body>
|
||||
</html>
|
||||
46
src/pages/about.astro
Normal file
46
src/pages/about.astro
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
import { SITE, siteTitle } from "../site-config";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import { getEntry } from "astro:content";
|
||||
|
||||
const about = await getEntry("about", "index");
|
||||
const bio = about?.data?.bio;
|
||||
---
|
||||
|
||||
<BaseLayout title={siteTitle("About")} description="About the artist">
|
||||
<Fragment slot="header">
|
||||
<Header />
|
||||
</Fragment>
|
||||
|
||||
<section class="about-page">
|
||||
<div class="container">
|
||||
<div class="about-layout">
|
||||
<div class="about-photo">
|
||||
<img
|
||||
src={about?.data?.photo ?? "/images/artist-photo.svg"}
|
||||
alt={SITE.name}
|
||||
width="300"
|
||||
height="300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="about-bio">
|
||||
<h1>About</h1>
|
||||
{bio ? (
|
||||
bio.split("\n\n").map((p) => <p>{p}</p>)
|
||||
) : (
|
||||
<p>
|
||||
{SITE.tagline} Based in {SITE.location}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Fragment slot="footer">
|
||||
<Footer />
|
||||
</Fragment>
|
||||
</BaseLayout>
|
||||
27
src/pages/contact.astro
Normal file
27
src/pages/contact.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { SITE, siteTitle } from "../site-config";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
---
|
||||
|
||||
<BaseLayout title={siteTitle("Contact")} description="Get in touch">
|
||||
<Fragment slot="header">
|
||||
<Header />
|
||||
</Fragment>
|
||||
|
||||
<section class="contact-page">
|
||||
<h1>Contact</h1>
|
||||
<p>
|
||||
For inquiries about commissions, exhibitions, or purchasing original works,
|
||||
please reach out via email.
|
||||
</p>
|
||||
<a class="contact-email" href={`mailto:${SITE.email}`}>
|
||||
{SITE.email}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<Fragment slot="footer">
|
||||
<Footer />
|
||||
</Fragment>
|
||||
</BaseLayout>
|
||||
56
src/pages/gallery.astro
Normal file
56
src/pages/gallery.astro
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import { SITE, siteTitle } from "../site-config";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import PaintingCard from "../components/PaintingCard.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const paintings = await getCollection("paintings");
|
||||
---
|
||||
|
||||
<BaseLayout title={siteTitle("Gallery")} description={SITE.description}>
|
||||
<Fragment slot="header">
|
||||
<Header />
|
||||
</Fragment>
|
||||
|
||||
<section style="padding: 2rem 0 4rem;">
|
||||
<div class="container">
|
||||
<div class="gallery-header">
|
||||
<h1>Gallery</h1>
|
||||
<p>A selection of recent works</p>
|
||||
</div>
|
||||
<div class="gallery-grid">
|
||||
{paintings.map((entry) => (
|
||||
<PaintingCard
|
||||
slug={entry.id}
|
||||
title={entry.data.title}
|
||||
image={entry.data.image}
|
||||
year={entry.data.year}
|
||||
medium={entry.data.medium}
|
||||
dimensions={entry.data.dimensions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Fragment slot="footer">
|
||||
<Footer />
|
||||
</Fragment>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
document.querySelectorAll(".fade-in").forEach((el) => observer.observe(el));
|
||||
</script>
|
||||
65
src/pages/index.astro
Normal file
65
src/pages/index.astro
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
import { SITE, siteTitle } from "../site-config";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import Hero from "../components/Hero.astro";
|
||||
import PaintingCard from "../components/PaintingCard.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const paintings = await getCollection("paintings");
|
||||
const featured = paintings.find((p) => p.data.featured);
|
||||
---
|
||||
|
||||
<BaseLayout title={siteTitle(SITE.title)} description={SITE.description}>
|
||||
<Fragment slot="header">
|
||||
<Header />
|
||||
</Fragment>
|
||||
|
||||
{featured && (
|
||||
<Hero
|
||||
image={featured.data.image}
|
||||
title={featured.data.title}
|
||||
subtitle={`${featured.data.title}, ${featured.data.year}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section style="padding: 1rem 0 4rem;">
|
||||
<div class="container">
|
||||
<div style="text-align: center; margin-bottom: 2.5rem;">
|
||||
<h2 style="font-family: var(--font-serif); font-size: 1.75rem; font-weight: 400;">All Paintings</h2>
|
||||
</div>
|
||||
<div class="gallery-grid">
|
||||
{paintings.map((entry) => (
|
||||
<PaintingCard
|
||||
slug={entry.id}
|
||||
title={entry.data.title}
|
||||
image={entry.data.image}
|
||||
year={entry.data.year}
|
||||
medium={entry.data.medium}
|
||||
dimensions={entry.data.dimensions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Fragment slot="footer">
|
||||
<Footer />
|
||||
</Fragment>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
document.querySelectorAll(".fade-in").forEach((el) => observer.observe(el));
|
||||
</script>
|
||||
56
src/pages/paintings/[...slug].astro
Normal file
56
src/pages/paintings/[...slug].astro
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import { siteTitle } from "../../site-config";
|
||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||
import Header from "../../components/Header.astro";
|
||||
import Footer from "../../components/Footer.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const paintings = await getCollection("paintings");
|
||||
return paintings.map((entry) => ({
|
||||
params: { slug: entry.id },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { data } = entry;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={siteTitle(data.title)}
|
||||
description={`${data.title}, ${data.year}, ${data.medium}`}
|
||||
>
|
||||
<Fragment slot="header">
|
||||
<Header />
|
||||
</Fragment>
|
||||
|
||||
<article class="painting-detail">
|
||||
<div class="container">
|
||||
<div class="painting-detail-image">
|
||||
<img
|
||||
src={data.image}
|
||||
alt={data.title}
|
||||
width="800"
|
||||
height="1067"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
<div class="painting-detail-info">
|
||||
<h1>{data.title}</h1>
|
||||
<div class="painting-detail-meta">
|
||||
<span>{data.year}</span>
|
||||
<span>{data.medium}</span>
|
||||
<span>{data.dimensions}</span>
|
||||
</div>
|
||||
{data.description && (
|
||||
<div class="painting-detail-description">{data.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Fragment slot="footer">
|
||||
<Footer />
|
||||
</Fragment>
|
||||
</BaseLayout>
|
||||
13
src/site-config.ts
Normal file
13
src/site-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const SITE = {
|
||||
name: "Linda Adel",
|
||||
title: "Oil Paintings",
|
||||
email: "laladel0@gmail.com",
|
||||
description: "Portfolio of oil paintings by Linda Adel",
|
||||
tagline: "Exploring the shifting qualities of natural light through oil painting.",
|
||||
location: "Alexandria, Egypt",
|
||||
url: "/",
|
||||
};
|
||||
|
||||
export function siteTitle(...parts: string[]) {
|
||||
return parts.length ? `${parts.join(" — ")} — ${SITE.name}` : SITE.name;
|
||||
}
|
||||
379
src/styles/global.css
Normal file
379
src/styles/global.css
Normal file
@@ -0,0 +1,379 @@
|
||||
:root {
|
||||
--bg: #f0e0ea;
|
||||
--surface: #ffffff;
|
||||
--text: #1a1120;
|
||||
--text-muted: #7a5a75;
|
||||
--border: #e8d0e4;
|
||||
--accent: #d4a0c0;
|
||||
--accent-light: #e8bcd4;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
|
||||
--radius: 4px;
|
||||
--max-width: 1200px;
|
||||
--content-width: 720px;
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-serif: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
a:hover { color: var(--accent-light); }
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.header-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.header-title a { color: var(--text); }
|
||||
.header-title a:hover { color: var(--accent); }
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.header-nav a { color: var(--text-muted); }
|
||||
.header-nav a:hover,
|
||||
.header-nav a[aria-current="page"] {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 3rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6rem;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
padding: 4rem 0 3rem;
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-image {
|
||||
aspect-ratio: 3 / 4;
|
||||
overflow: hidden;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.hero-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.6s ease;
|
||||
}
|
||||
.hero-image:hover img { transform: scale(1.03); }
|
||||
.hero-content h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hero-content .subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.hero-content p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Gallery grid */
|
||||
.gallery-header {
|
||||
padding: 3rem 0 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.gallery-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.gallery-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Painting card */
|
||||
.painting-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.painting-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.painting-card-image {
|
||||
aspect-ratio: 3 / 4;
|
||||
overflow: hidden;
|
||||
background: var(--border);
|
||||
}
|
||||
.painting-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.painting-card:hover .painting-card-image img {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.painting-card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.painting-card-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.painting-card-meta {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Painting detail */
|
||||
.painting-detail {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
.painting-detail-image {
|
||||
max-width: 800px;
|
||||
margin: 0 auto 2rem;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.painting-detail-info {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.painting-detail-info h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.painting-detail-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.painting-detail-meta span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.painting-detail-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-muted);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* About */
|
||||
.about-page {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
.about-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 3rem;
|
||||
align-items: start;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.about-photo {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
}
|
||||
.about-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.about-bio {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.about-bio h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Contact */
|
||||
.contact-page {
|
||||
padding: 3rem 0;
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.contact-page h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.contact-page p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.contact-email {
|
||||
display: inline-block;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
padding: 1rem 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.contact-email:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Fade-in animation */
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
.fade-in.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.hero-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
.hero-content h1 { font-size: 2rem; }
|
||||
.hero-image { aspect-ratio: 4 / 3; }
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.about-layout {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
.about-photo {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.painting-detail-image {
|
||||
max-width: 100%;
|
||||
}
|
||||
.painting-detail-meta {
|
||||
gap: 1rem;
|
||||
}
|
||||
.header-inner {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.header-nav {
|
||||
gap: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.contact-email {
|
||||
font-size: 1.125rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero { padding: 2rem 0; }
|
||||
.hero-content h1 { font-size: 1.75rem; }
|
||||
.gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.container { padding: 0 1rem; }
|
||||
.painting-detail-meta {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user