Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Upstash Redis (visitor counter)
# Get these from https://console.upstash.com → Redis → your database → REST API
UPSTASH_REDIS_REST_URL=https://your-endpoint.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token-here
82 changes: 82 additions & 0 deletions src/components/BackToTop.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<!-- Back to top button: shows after scrolling 500px -->
<button id="back-to-top" class="back-to-top" aria-label="Back to top" title="Back to top">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>

<script is:inline>
(function() {
var btn = document.getElementById('back-to-top');
if (!btn) return;

var visible = false;
function toggle() {
var show = window.scrollY > 500;
if (show !== visible) {
visible = show;
btn.classList.toggle('back-to-top-visible', show);
}
}

window.addEventListener('scroll', toggle, { passive: true });
toggle();

btn.addEventListener('click', function() {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
})();
</script>

<style>
.back-to-top {
position: fixed;
bottom: 90px;
right: var(--space-xl, 24px);
z-index: 8000;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated, #fff);
border: 1px solid var(--border, #e5e5e5);
border-radius: var(--radius-full, 999px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
color: var(--text-muted, #666);
cursor: pointer;
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease, color 0.15s ease, border-color 0.15s ease;
}

.back-to-top-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}

.back-to-top:hover {
color: var(--text, #333);
border-color: var(--border-strong, #ccc);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

@media (max-width: 480px) {
.back-to-top {
right: var(--space-md, 12px);
bottom: 80px;
}
}

@media (prefers-reduced-motion: reduce) {
.back-to-top {
transition: opacity 0.1s ease;
transform: none;
}
.back-to-top-visible {
transform: none;
}
}
</style>
171 changes: 142 additions & 29 deletions src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,78 @@ const year = new Date().getFullYear();

<footer class="footer">
<div class="container footer-inner">
<div class="footer-brand">
<div class="footer-col footer-brand-col">
<span class="footer-logo">
<img src="/logo.svg" alt="OGCOPS" class="footer-logo-img" width="100" height="25" />
</span>
<span class="footer-tagline">Free OG Image Generator</span>
<span class="footer-byline">A tool by <a href="https://www.codercops.com" target="_blank" rel="noopener">CODERCOPS</a></span>
</div>
<div class="footer-links">
<a href="/create">Create</a>
<a href="/preview">Preview</a>
<a href="/templates">Templates</a>
<a href="/api-docs">API</a>
<a href="https://github.com/codercops/ogcops" target="_blank" rel="noopener">GitHub</a>

<div class="footer-col footer-links-col">
<span class="footer-col-title">Product</span>
<nav class="footer-nav">
<a href="/create">Create</a>
<a href="/preview">Preview</a>
<a href="/templates">Templates</a>
<a href="/api-docs">API Docs</a>
<a href="https://github.com/codercops/ogcops" target="_blank" rel="noopener">GitHub</a>
</nav>
</div>
<div class="footer-meta">
<span class="footer-mit">MIT License</span>
<span class="footer-copy">&copy; {year} <a href="https://codercops.com" target="_blank" rel="noopener">CODERCOPS</a></span>

<div class="footer-col footer-stats-col">
<span class="footer-col-title">Stats</span>
<div class="footer-stats" aria-label="Visitor statistics">
<span class="stat-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Today: <span id="visitors-today" class="stat-num">-</span>
</span>
<span class="stat-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Total: <span id="visitors-total" class="stat-num">-</span>
</span>
</div>
<div class="footer-meta">
<span class="footer-mit">MIT License</span>
<span class="footer-copy">&copy; {year} <a href="https://codercops.com" target="_blank" rel="noopener">CODERCOPS</a></span>
</div>
</div>
</div>
</footer>

<style>
.footer {
border-top: 1px solid var(--border);
padding: var(--space-2xl) 0;
position: relative;
padding: var(--space-3xl) 0 var(--space-2xl);
margin-top: auto;
background: linear-gradient(180deg, transparent 0%, rgba(224, 122, 95, 0.02) 100%);
}

.footer::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, var(--border) 20%, var(--accent-primary-muted) 50%, var(--border) 80%, transparent 100%);
}

.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-lg);
display: grid;
grid-template-columns: 1.2fr 0.8fr 1fr;
gap: var(--space-2xl);
}

.footer-brand {
display: flex;
align-items: center;
gap: var(--space-md);
.footer-col { display: flex; flex-direction: column; gap: var(--space-sm); }

.footer-col-title {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent-primary);
margin-bottom: var(--space-sm);
}

.footer-logo {
Expand All @@ -52,34 +85,76 @@ const year = new Date().getFullYear();

.footer-logo-img {
display: block;
height: 22px;
height: 24px;
width: auto;
}

.footer-tagline {
font-size: var(--text-sm);
color: var(--text-muted);
}

.footer-byline {
font-size: var(--text-xs);
color: var(--text-subtle);
}

.footer-links {
.footer-byline a {
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
transition: color var(--transition);
}

.footer-byline a:hover {
color: var(--accent-primary);
}

.footer-nav {
display: flex;
gap: var(--space-lg);
flex-direction: column;
gap: var(--space-sm);
}

.footer-links a {
.footer-nav a {
font-size: var(--text-sm);
color: var(--text-muted);
transition: color var(--transition);
}

.footer-links a:hover { color: var(--text); }
.footer-nav a:hover { color: var(--text); }

.footer-stats {
display: flex;
flex-direction: column;
gap: var(--space-sm);
font-size: var(--text-xs);
color: var(--text-subtle);
}

.stat-item {
display: inline-flex;
align-items: center;
gap: 6px;
}

.stat-item svg {
opacity: 0.6;
}

.stat-num {
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-weight: 600;
color: var(--text-muted);
}

.footer-meta {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--text-sm);
font-size: var(--text-xs);
color: var(--text-subtle);
margin-top: var(--space-md);
}

.footer-mit {
Expand All @@ -98,9 +173,47 @@ const year = new Date().getFullYear();

@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
grid-template-columns: 1fr;
text-align: center;
gap: var(--space-xl);
}
.footer-links { flex-wrap: wrap; justify-content: center; }
.footer-col { align-items: center; }
.footer-meta { justify-content: center; }
}
</style>

<script is:inline>
(function() {
function updateDOM(data) {
var today = document.getElementById('visitors-today');
var total = document.getElementById('visitors-total');
if (today) today.textContent = (data.today || 0).toLocaleString();
if (total) total.textContent = (data.total || 0).toLocaleString();
}

function loadVisitors() {
// One increment per session
if (!sessionStorage.getItem('ogcops-counted')) {
fetch('/api/visitors', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
sessionStorage.setItem('ogcops-counted', '1');
updateDOM(data);
})
.catch(function(e) { console.warn('Visitor counter error:', e); });
} else {
fetch('/api/visitors')
.then(function(r) { return r.json(); })
.then(updateDOM)
.catch(function(e) { console.warn('Visitor counter error:', e); });
}
}

// Ensure DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadVisitors);
} else {
loadVisitors();
}
})();
</script>
Loading