sync
This commit is contained in:
@@ -47,6 +47,29 @@ location /images/ {
|
||||
}
|
||||
```
|
||||
|
||||
Blog support:
|
||||
- Add plain text files to the `blog/` directory. Supported extensions are `.txt` and `.text`. Each file should start with a date/time line, then a title line, then the body.
|
||||
- To attach a file, place it in `blog/files/` and reference it in the post with `[[file: filename.ext]]` or `[[attachment: filename.ext]]`.
|
||||
- Run `python3 scripts/generate_blog_manifest.py` from the project root to refresh `blog/manifest.json` after adding or removing posts.
|
||||
|
||||
Git sync:
|
||||
- Use `./scripts/sync_site.sh` from the website root to pull changes from Git via HTTP.
|
||||
- The default remote is `https://git.organic-server.org/organic-CPU/website-but-better.git` and the branch is `main`.
|
||||
- By default it deploys to `/var/www/html`, which is the default nginx document root on most Linux distributions.
|
||||
- Override with `GIT_REPO_URL`, `GIT_BRANCH`, or `DEPLOY_DIR` if needed.
|
||||
|
||||
Automatic sync every 12 hours:
|
||||
- Copy `scripts/sync_site.service` and `scripts/sync_site.timer` to `/etc/systemd/system/`.
|
||||
- Update `sync_site.service` to point at the actual repository path on your server.
|
||||
- Then enable and start the timer:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now sync_site.timer
|
||||
```
|
||||
|
||||
This makes the website sync twice per day and deploy the latest files to nginx root.
|
||||
|
||||
Editing the Git link:
|
||||
- Update the header Git link in `index.html` at the element with id `git-link` to your repository URL.
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Blog — Marcus Allison</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<h1>Blog</h1>
|
||||
<p class="tag">Updates from my self-hosting and systems administration journey.</p>
|
||||
<p class="header-links"><a href="index.html">Home</a><a href="https://git.organic-server.org">Gitea</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section id="blog-intro">
|
||||
<h2>Latest Posts</h2>
|
||||
<p>Each entry is a plain text file in the <code>blog/</code> directory. The first line is the date/time, the second line is the title, and the rest is the post content.</p>
|
||||
<p>To attach a file, put it in <code>blog/files/</code> and reference it using <code>[[file: filename.ext]]</code> or <code>[[attachment: filename.ext]]</code> in the body.</p>
|
||||
</section>
|
||||
|
||||
<section id="blog-list">
|
||||
<div class="blog-feed">Loading blog posts…</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container">© Marcus Allison - Self-hosting & Systems Administration</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/blog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
2026-07-09 18:30
|
||||
Welco
|
||||
|
||||
This blog is a simple read-only collection of notes from my home lab and self-hosting experiments. Add a new text file to the `blog/` folder with a date/time on the first line, a title on the second line, and the content after a blank line.
|
||||
@@ -0,0 +1,4 @@
|
||||
2026-07-09 18:30
|
||||
Welcome to the blog
|
||||
|
||||
This blog is a simple read-only collection of notes from my home lab and self-hosting experiments. Add a new text file to the `blog/` folder with a date/time on the first line, a title on the second line, and the content after a blank line.
|
||||
@@ -0,0 +1,6 @@
|
||||
2026-07-10 12:45
|
||||
Attachment test
|
||||
|
||||
This post includes a referenced attachment file.
|
||||
|
||||
You can download it here: [[file: sample-attachment.txt]]
|
||||
@@ -0,0 +1 @@
|
||||
This is a sample attachment file used to test blog file linking.
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"2026-06-06.text",
|
||||
"2026-07-09-welcome.txt",
|
||||
"2026-07-10-attachment-test.txt"
|
||||
]
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
<div class="container">
|
||||
<h1>Marcus Allison</h1>
|
||||
<p class="tag">Systems Administrator · Self-Hosting · Network Security</p>
|
||||
<p class="header-links"><a id="git-link" href="https://git.organic-server.org">Gitea</a></p>
|
||||
<p class="header-links"><a href="blog.html">Blog</a> <a id="git-link" href="https://git.organic-server.org">Gitea</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
const BLOG_DIR = 'blog/';
|
||||
const ATTACHMENT_DIR = BLOG_DIR + 'files/';
|
||||
const MANIFEST = BLOG_DIR + 'manifest.json';
|
||||
const TXT_EXT = /\.(txt|text)$/i;
|
||||
const ATTACHMENT_RE = /\[\[(?:file|attachment):\s*([^\]]+)\]\]/gi;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function parseIndexHtmlList(text) {
|
||||
const hrefs = [];
|
||||
const re = /href="([^"]+)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const name = m[1];
|
||||
if (TXT_EXT.test(name)) hrefs.push(name);
|
||||
}
|
||||
return hrefs;
|
||||
}
|
||||
|
||||
async function loadBlogFiles() {
|
||||
try {
|
||||
const response = await fetch(MANIFEST, { cache: 'no-cache' });
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch (e) {
|
||||
// manifest missing or unreadable
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(BLOG_DIR, { cache: 'no-cache' });
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
return parseIndexHtmlList(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not read blog directory listing', e);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseDateLine(rawDate) {
|
||||
if (!rawDate) return null;
|
||||
let value = rawDate.trim();
|
||||
if (value.toLowerCase().startsWith('date:')) {
|
||||
value = value.slice(5).trim();
|
||||
}
|
||||
if (!value) return null;
|
||||
const normalized = value.replace(' ', 'T');
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function extractAttachments(body) {
|
||||
const attachments = new Set();
|
||||
body.replace(ATTACHMENT_RE, (_, filename) => {
|
||||
const safeName = filename.trim();
|
||||
if (safeName) attachments.add(safeName);
|
||||
return '';
|
||||
});
|
||||
return Array.from(attachments);
|
||||
}
|
||||
|
||||
function parseBlogText(text, filename) {
|
||||
const lines = text.replace(/\r/g, '').split('\n');
|
||||
let index = 0;
|
||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
||||
|
||||
const rawDate = index < lines.length ? lines[index].trim() : '';
|
||||
if (rawDate.toLowerCase().startsWith('date:')) {
|
||||
index += 1;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
||||
const title = index < lines.length ? lines[index].trim() : 'Untitled';
|
||||
index += 1;
|
||||
|
||||
while (index < lines.length && !lines[index].trim()) index += 1;
|
||||
const body = lines.slice(index).join('\n').trim();
|
||||
|
||||
return {
|
||||
filename,
|
||||
title: title || 'Untitled',
|
||||
date: parseDateLine(rawDate) || null,
|
||||
dateLabel: rawDate || '',
|
||||
body: body || '',
|
||||
attachments: extractAttachments(body),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return 'Unknown date';
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function createParagraphs(body) {
|
||||
return body
|
||||
.split(/\n{2,}/)
|
||||
.filter(Boolean)
|
||||
.map((paragraph) => {
|
||||
const p = document.createElement('p');
|
||||
const text = escapeHtml(paragraph).replace(ATTACHMENT_RE, (match, filename) => {
|
||||
const safeName = filename.trim();
|
||||
const url = ATTACHMENT_DIR + encodeURI(safeName);
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(safeName)}</a>`;
|
||||
});
|
||||
const lines = text.split('\n');
|
||||
p.innerHTML = lines.join('<br>');
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
function renderBlogEntries(entries) {
|
||||
const container = document.querySelector('.blog-feed');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!entries.length) {
|
||||
container.textContent = 'No posts found. Add .txt files to the blog/ folder and enable blog manifest or directory listing.';
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const article = document.createElement('article');
|
||||
article.className = 'blog-post';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'blog-title';
|
||||
title.textContent = entry.title;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'blog-meta';
|
||||
meta.textContent = entry.date ? formatDate(entry.date) : entry.dateLabel || 'Date not provided';
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'blog-body';
|
||||
const paragraphs = createParagraphs(entry.body);
|
||||
if (!paragraphs.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.textContent = 'No content yet.';
|
||||
body.appendChild(empty);
|
||||
} else {
|
||||
paragraphs.forEach((p) => body.appendChild(p));
|
||||
}
|
||||
|
||||
if (entry.attachments && entry.attachments.length) {
|
||||
const attachTitle = document.createElement('div');
|
||||
attachTitle.className = 'blog-meta';
|
||||
attachTitle.textContent = 'Attachments:';
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'blog-attachment-list';
|
||||
entry.attachments.forEach((attachment) => {
|
||||
const item = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
link.href = ATTACHMENT_DIR + encodeURI(attachment);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = attachment;
|
||||
item.appendChild(link);
|
||||
list.appendChild(item);
|
||||
});
|
||||
article.appendChild(attachTitle);
|
||||
article.appendChild(list);
|
||||
}
|
||||
|
||||
article.appendChild(title);
|
||||
article.appendChild(meta);
|
||||
article.appendChild(body);
|
||||
container.appendChild(article);
|
||||
});
|
||||
}
|
||||
|
||||
function sortEntries(entries) {
|
||||
return entries.slice().sort((a, b) => {
|
||||
if (a.date && b.date) return b.date - a.date;
|
||||
if (a.date) return -1;
|
||||
if (b.date) return 1;
|
||||
return a.filename.localeCompare(b.filename);
|
||||
});
|
||||
}
|
||||
|
||||
async function initBlog() {
|
||||
const fileNames = await loadBlogFiles();
|
||||
if (!fileNames.length) {
|
||||
renderBlogEntries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = fileNames
|
||||
.filter((name) => TXT_EXT.test(name))
|
||||
.map(async (name) => {
|
||||
try {
|
||||
const response = await fetch(BLOG_DIR + name, { cache: 'no-cache' });
|
||||
if (!response.ok) throw new Error('Failed to load ' + name);
|
||||
const text = await response.text();
|
||||
return parseBlogText(text, name);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const entries = (await Promise.all(promises)).filter(Boolean);
|
||||
renderBlogEntries(sortEntries(entries));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initBlog);
|
||||
@@ -1,27 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name portfolio.example.com www.portfolio.example.com;
|
||||
|
||||
root /var/www/marcus;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Uncomment to redirect all HTTP to HTTPS if you provision certs separately:
|
||||
# return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Example HTTPS block (use certbot or your CA to populate cert paths):
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name portfolio.example.com;
|
||||
# root /var/www/marcus;
|
||||
# index index.html;
|
||||
# ssl_certificate /etc/letsencrypt/live/portfolio.example.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/portfolio.example.com/privkey.pem;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
# location / { try_files $uri $uri/ =404; }
|
||||
# }
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate `blog/manifest.json` from the text files present in the blog/ folder.
|
||||
Run this in the project root to refresh the manifest after adding/removing blog entries.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
BASEDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
BLOGDIR = os.path.join(BASEDIR, 'blog')
|
||||
|
||||
|
||||
def main():
|
||||
exts = ('.txt', '.text')
|
||||
files = [f for f in sorted(os.listdir(BLOGDIR)) if f.lower().endswith(exts)]
|
||||
out = os.path.join(BLOGDIR, 'manifest.json')
|
||||
with open(out, 'w', encoding='utf-8') as fh:
|
||||
json.dump(files, fh, indent=2)
|
||||
print(f'Wrote {len(files)} entries to {out}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Sync website content from Git repository
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=%h/website-but-better
|
||||
Environment=GIT_REPO_URL=https://git.organic-server.org/organic-CPU/website-but-better.git
|
||||
Environment=GIT_BRANCH=main
|
||||
Environment=DEPLOY_DIR=/var/www/html
|
||||
ExecStart=/bin/bash /home/USERNAME/website-but-better/scripts/sync_site.sh
|
||||
User=www-data
|
||||
Group=www-data
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REPO_URL="${GIT_REPO_URL:-https://git.organic-server.org/organic-CPU/website-but-better.git}"
|
||||
BRANCH="${GIT_BRANCH:-main}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/var/www/html}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if [ ! -d .git ]; then
|
||||
if [ "$(find . -maxdepth 1 -mindepth 1 | wc -l)" -eq 0 ]; then
|
||||
echo "Cloning website repository from $REPO_URL into $ROOT_DIR"
|
||||
git clone "$REPO_URL" .
|
||||
else
|
||||
echo "Error: $ROOT_DIR is not a git repository and is not empty." >&2
|
||||
echo "Please clone the repository into this directory or initialize git manually." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Syncing website from $REPO_URL ($BRANCH)"
|
||||
git remote set-url origin "$REPO_URL"
|
||||
git fetch origin --prune
|
||||
|
||||
git checkout "$BRANCH"
|
||||
git pull --ff-only origin "$BRANCH"
|
||||
|
||||
echo "Regenerating manifests"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 scripts/generate_blog_manifest.py
|
||||
python3 scripts/generate_manifest.py
|
||||
else
|
||||
echo "Warning: python3 not found; manifest regeneration skipped." >&2
|
||||
fi
|
||||
|
||||
if [ "$DEPLOY_DIR" != "$ROOT_DIR" ]; then
|
||||
echo "Deploying website files to $DEPLOY_DIR"
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'scripts/' \
|
||||
--exclude 'nginx/' \
|
||||
--exclude '.gitignore' \
|
||||
--exclude '*.sh' \
|
||||
"$ROOT_DIR/" "$DEPLOY_DIR/"
|
||||
fi
|
||||
|
||||
echo "Website sync complete."
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run website sync every 12 hours
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 00,12:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+19
-8
@@ -1,8 +1,8 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; color:#16202A; background:#f7fafc; line-height:1.6; }
|
||||
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; margin:0; color:#2b0f0f; background:#fff5f5; line-height:1.6; }
|
||||
.container { max-width:980px; margin:0 auto; padding:24px; }
|
||||
.site-header { background:linear-gradient(90deg,#0f172a,#0b1320); color:#fff; padding:20px 0; border-bottom:3px solid #0f62fe; }
|
||||
.site-header { background:linear-gradient(90deg,#7f1d1d,#4b1111); color:#fff; padding:20px 0; border-bottom:3px solid #dc2626; }
|
||||
.site-header .container { padding:20px 24px; }
|
||||
.site-header h1 { margin:0; font-size:2.2rem; font-weight:700; letter-spacing:-0.5px; }
|
||||
.tag { margin-top:6px; opacity:0.9; font-size:0.95rem; }
|
||||
@@ -10,19 +10,30 @@ body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neu
|
||||
.card { background:#fff; padding:20px; border-radius:8px; box-shadow:0 1px 3px rgba(2,6,23,0.06); transition:all 0.3s ease; border:1px solid #e5e7eb; }
|
||||
.card:hover { transform:translateY(-2px); box-shadow:0 8px 16px rgba(2,6,23,0.1); }
|
||||
.site-footer { text-align:center; padding:24px; margin-top:40px; font-size:0.9rem; color:#6b7280; border-top:1px solid #e5e7eb; background:#fafafa; }
|
||||
h2 { margin:32px 0 16px 0; font-size:1.8rem; font-weight:700; color:#0f172a; padding-bottom:12px; border-bottom:2px solid #f0f0f0; }
|
||||
h3 { margin:0 0 8px 0; font-size:1.1rem; font-weight:600; color:#0f172a; }
|
||||
h2 { margin:32px 0 16px 0; font-size:1.8rem; font-weight:700; color:#7f1d1d; padding-bottom:12px; border-bottom:2px solid #fee2e2; }
|
||||
h3 { margin:0 0 8px 0; font-size:1.1rem; font-weight:600; color:#7f1d1d; }
|
||||
section { margin-bottom:16px; }
|
||||
p { margin:0 0 12px 0; }
|
||||
ol, ul { margin:12px 0; padding-left:24px; }
|
||||
li { margin-bottom:8px; }
|
||||
a { color:#0f62fe; text-decoration:none; transition:color 0.2s ease; }
|
||||
a:hover { color:#0b47b8; text-decoration:underline; }
|
||||
a { color:#dc2626; text-decoration:none; transition:color 0.2s ease; }
|
||||
a:hover { color:#991b1b; text-decoration:underline; }
|
||||
|
||||
#links ul { list-style:none; padding:0; display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:12px; margin:16px 0; }
|
||||
#links li { margin:0; }
|
||||
#links a { display:block; padding:12px 16px; background:#fff; border:1px solid #e5e7eb; border-radius:6px; transition:all 0.3s ease; }
|
||||
#links a:hover { border-color:#0f62fe; box-shadow:0 4px 12px rgba(15,98,254,0.15); }
|
||||
#links a:hover { border-color:#dc2626; box-shadow:0 4px 12px rgba(220,38,38,0.15); }
|
||||
.blog-feed { display:grid; gap:24px; margin-top:16px; }
|
||||
.blog-post { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:24px; box-shadow:0 1px 6px rgba(139,18,18,0.08); }
|
||||
.blog-post + .blog-post { border-top:4px solid #dc2626; margin-top:32px; padding-top:32px; }
|
||||
.blog-title { margin:0 0 8px 0; font-size:1.5rem; }
|
||||
.blog-meta { margin:0 0 16px 0; color:#7f1d1d; font-size:0.95rem; }
|
||||
.blog-body p { margin:0 0 1em 0; }
|
||||
.blog-body p:last-child { margin-bottom:0; }
|
||||
.blog-attachment-list { margin:16px 0 0 0; padding:0; list-style:none; }
|
||||
.blog-attachment-list li { margin:0.5em 0; }
|
||||
.blog-attachment-list a { color:#dc2626; text-decoration:none; }
|
||||
.blog-attachment-list a:hover { text-decoration:underline; }
|
||||
@media (max-width:520px){ h2 { font-size:1.4rem; } .site-header h1 { font-size:1.6rem; } .container { padding:16px; } }
|
||||
|
||||
.header-links { margin-top:12px; }
|
||||
@@ -31,7 +42,7 @@ a:hover { color:#0b47b8; text-decoration:underline; }
|
||||
|
||||
.gallery { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:12px; margin-top:16px; }
|
||||
.gallery img { width:100%; height:150px; object-fit:cover; border-radius:8px; cursor:pointer; display:block; transition:all 0.3s ease; border:2px solid transparent; }
|
||||
.gallery img:hover { transform:scale(1.04); box-shadow:0 4px 12px rgba(2,6,23,0.15); border-color:#0f62fe; }
|
||||
.gallery img:hover { transform:scale(1.04); box-shadow:0 4px 12px rgba(139,18,18,0.15); border-color:#dc2626; }
|
||||
|
||||
.modal { position:fixed; inset:0; background:rgba(2,6,23,0.95); display:flex; align-items:center; justify-content:center; z-index:60; backdrop-filter:blur(2px); }
|
||||
.modal.hidden { display:none; }
|
||||
|
||||
Reference in New Issue
Block a user