more features
This commit is contained in:
30
.claude/settings.local.json
Normal file
30
.claude/settings.local.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dist/linkbeam:*)",
|
||||||
|
"Bash(docker build:*)",
|
||||||
|
"Bash(go run:*)",
|
||||||
|
"Bash(go test -v ./...)",
|
||||||
|
"Bash(go test:*)",
|
||||||
|
"Bash(golangci-lint help:*)",
|
||||||
|
"Bash(golangci-lint run:*)",
|
||||||
|
"Bash(make:*)",
|
||||||
|
"Bash(sudo docker build:*)",
|
||||||
|
"Bash(docker compose:*)",
|
||||||
|
"Bash(sudo docker compose build:*)",
|
||||||
|
"Bash(sudo docker compose:*)",
|
||||||
|
"Bash(./dist/linkbeam:*)",
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Read(//tmp/**)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(for file in internal/config/testdata/config-{nord,gruvbox,catppuccin}.yaml)",
|
||||||
|
"Bash(do)",
|
||||||
|
"Bash(done)",
|
||||||
|
"Bash(for file in catppuccin-latte.css catppuccin-frappe.css catppuccin-macchiato.css)",
|
||||||
|
"Bash(do grep -n \"main a {\" \"$file\")",
|
||||||
|
"Bash(go build:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -38,3 +38,10 @@ config.yaml
|
|||||||
|
|
||||||
# Pre-commit
|
# Pre-commit
|
||||||
.pre-commit-cache/
|
.pre-commit-cache/
|
||||||
|
|
||||||
|
static/
|
||||||
|
!static/favicon.png
|
||||||
|
!static/logo.png
|
||||||
|
!static/ada-256x256.png
|
||||||
|
|
||||||
|
!.gitignore
|
||||||
|
|||||||
162
HTML Theme Switcher Implementation.md
Normal file
162
HTML Theme Switcher Implementation.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
A three-option theme toggle (System/Light/Dark) that works reliably across all browsers.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Cycles through: System (💡) → Light (☀️) → Dark (🌙)
|
||||||
|
- Persists across page refreshes
|
||||||
|
- Follows OS theme preference when "System" is selected
|
||||||
|
- Works in private browsing mode
|
||||||
|
- Mobile-friendly (icon-only)
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
Three separate rules that work together:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Default: Light mode */
|
||||||
|
:root {
|
||||||
|
--bg-primary: var(--nord-snow-storm-3);
|
||||||
|
--text-primary: var(--nord-polar-night-2);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-primary: var(--nord-polar-night-1);
|
||||||
|
--text-primary: var(--nord-snow-storm-3);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual overrides (highest priority) */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--bg-primary: var(--nord-polar-night-1);
|
||||||
|
--text-primary: var(--nord-snow-storm-3);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg-primary: var(--nord-snow-storm-3);
|
||||||
|
--text-primary: var(--nord-polar-night-2);
|
||||||
|
/* ... */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this works:** `html[data-theme]` has higher specificity than the media query, so manual selection always overrides system preference.
|
||||||
|
|
||||||
|
### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const THEME_OPTIONS = ['system', 'light', 'dark'];
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = getFromStorage('themePreference', 'system');
|
||||||
|
const nextIndex = (THEME_OPTIONS.indexOf(current) + 1) % 3;
|
||||||
|
const next = THEME_OPTIONS[nextIndex];
|
||||||
|
|
||||||
|
applyTheme(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
// Update HTML attribute
|
||||||
|
if (theme === 'system') {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById('themeIcon').textContent = THEME_ICONS[theme];
|
||||||
|
document.getElementById('themeLabel').textContent = THEME_LABELS[theme];
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
saveToStorage('themePreference', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
initTheme();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Fallback
|
||||||
|
|
||||||
|
Works even when localStorage is blocked:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function getStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('__test__', '1');
|
||||||
|
localStorage.removeItem('__test__');
|
||||||
|
return localStorage;
|
||||||
|
} catch {
|
||||||
|
return sessionStorage; // Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why It Works
|
||||||
|
|
||||||
|
1. **System mode**: No `data-theme` attribute → media query applies
|
||||||
|
2. **Light mode**: `data-theme="light"` → overrides media query
|
||||||
|
3. **Dark mode**: `data-theme="dark"` → overrides media query
|
||||||
|
|
||||||
|
## Firefox Fix
|
||||||
|
|
||||||
|
**Problem:** Initial implementation had invalid CSS:
|
||||||
|
```css
|
||||||
|
/* ❌ Invalid */
|
||||||
|
html[data-theme="dark"],
|
||||||
|
@media (prefers-color-scheme: dark) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Separate rules:
|
||||||
|
```css
|
||||||
|
/* ✅ Valid */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
12 unit tests verify:
|
||||||
|
- Theme storage/retrieval
|
||||||
|
- localStorage → sessionStorage fallback
|
||||||
|
- All three theme options work
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
## Available Themes
|
||||||
|
|
||||||
|
LinkBeam includes the following themes with automatic light/dark switching:
|
||||||
|
|
||||||
|
1. **auto** - Simple default theme
|
||||||
|
2. **nord** - Nord color palette (light/dark variants)
|
||||||
|
3. **gruvbox** - Gruvbox color palette (light/dark variants)
|
||||||
|
4. **catppuccin-latte** - Catppuccin Latte → Mocha (light → dark)
|
||||||
|
5. **catppuccin-frappe** - Catppuccin Latte → Frappé (light → dark)
|
||||||
|
6. **catppuccin-macchiato** - Catppuccin Latte → Macchiato (light → dark)
|
||||||
|
7. **catppuccin-mocha** - Catppuccin Latte → Mocha (light → dark)
|
||||||
|
|
||||||
|
All themes follow the same CSS structure with:
|
||||||
|
- Default light mode colors in `:root`
|
||||||
|
- Dark mode colors in `@media (prefers-color-scheme: dark)`
|
||||||
|
- Manual overrides via `html[data-theme="light|dark"]`
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `templates/base.html` - Theme switcher button and script
|
||||||
|
- `themes/*.css` - Theme CSS files with light/dark variants
|
||||||
|
- `internal/config/config.go` - Auto-discovers available themes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** ✅ Working in all browsers
|
||||||
|
**Last Updated:** 2025-10-12
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 36 KiB |
@@ -2,36 +2,86 @@
|
|||||||
# Example configuration for LinkBeam
|
# Example configuration for LinkBeam
|
||||||
|
|
||||||
---
|
---
|
||||||
name: "Your Name"
|
name: "Ada Lovelace"
|
||||||
bio: "A short bio about yourself"
|
bio: "Mathematician, writer, and world's first computer programmer"
|
||||||
avatar: "static/logo.png" # Can be a local path or URL (http://... or https://...)
|
avatar: "static/ada-256x256.png" # Can be a local path or URL (http://... or https://...)
|
||||||
theme: "auto"
|
theme: "auto" # Options: auto, nord, gruvbox, catppuccin-mocha, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato
|
||||||
# font_awesome_cdn: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" # Optional: custom Font Awesome CDN URL (default: Font Awesome 6.5.1)
|
|
||||||
links:
|
# Content blocks - flexible structure with named collections
|
||||||
- title: "My Website"
|
# Each block can contain any named collection of items
|
||||||
url: "https://yourwebsite.com"
|
# Item types: url (clickable link), copy-text (click to copy), text (plain display)
|
||||||
icon: "fas fa-globe"
|
|
||||||
- title: "Blog"
|
content:
|
||||||
url: "https://yourblog.com"
|
# Vertical list with text labels - good for primary links
|
||||||
icon: "fas fa-blog"
|
- type: vertical-list-text
|
||||||
- title: "GitHub"
|
links:
|
||||||
url: "https://github.com/yourusername"
|
- title: "My Research Papers"
|
||||||
|
url: "https://ada.blog/research"
|
||||||
|
icon: "fas fa-file-alt"
|
||||||
|
- title: "Analytical Engine Notes"
|
||||||
|
url: "https://ada.blog/notes"
|
||||||
|
icon: "fas fa-scroll"
|
||||||
|
- title: "GitHub Projects"
|
||||||
|
url: "https://github.com/ada"
|
||||||
icon: "fab fa-github"
|
icon: "fab fa-github"
|
||||||
- title: "Portfolio"
|
- title: "Speaking Events"
|
||||||
url: "https://portfolio.com"
|
url: "https://ada.blog/events"
|
||||||
icon: "fas fa-briefcase"
|
icon: "fas fa-calendar"
|
||||||
|
|
||||||
socials:
|
# Another vertical list - gaming profiles with copy-text
|
||||||
- platform: "Twitter"
|
- type: vertical-list-text
|
||||||
url: "https://twitter.com/yourusername"
|
gaming:
|
||||||
|
- title: "Steam"
|
||||||
|
url: "https://steamcommunity.com/id/ada_lovelace"
|
||||||
|
icon: "fab fa-steam"
|
||||||
|
- title: "Discord"
|
||||||
|
copy-text: "AdaLovelace#1842"
|
||||||
|
icon: "fab fa-discord"
|
||||||
|
- title: "Epic Games"
|
||||||
|
copy-text: "ada_lovelace"
|
||||||
|
icon: "fas fa-gamepad"
|
||||||
|
|
||||||
|
# Image gallery - displays images with optional captions
|
||||||
|
- type: vertical-list-images
|
||||||
|
gallery:
|
||||||
|
- image: "static/ada-256x256.png"
|
||||||
|
alt-text: "Portrait of Ada Lovelace"
|
||||||
|
text: "The world's first computer programmer"
|
||||||
|
- image: "https://picsum.photos/600/400"
|
||||||
|
alt-text: "Sample external image"
|
||||||
|
text: "Images can be local files or external URLs"
|
||||||
|
|
||||||
|
# Horizontal icon list - perfect for social media
|
||||||
|
- type: horizontal-list-icons
|
||||||
|
socials:
|
||||||
|
- title: "Twitter"
|
||||||
|
url: "https://twitter.com/ada_lovelace"
|
||||||
icon: "fab fa-twitter"
|
icon: "fab fa-twitter"
|
||||||
- platform: "LinkedIn"
|
- title: "LinkedIn"
|
||||||
url: "https://linkedin.com/in/yourusername"
|
url: "https://linkedin.com/in/ada-lovelace"
|
||||||
icon: "fab fa-linkedin"
|
icon: "fab fa-linkedin-in"
|
||||||
- platform: "Instagram"
|
- title: "Mastodon"
|
||||||
url: "https://instagram.com/yourusername"
|
url: "https://mastodon.social/@ada"
|
||||||
icon: "fab fa-instagram"
|
icon: "fab fa-mastodon"
|
||||||
|
- title: "YouTube"
|
||||||
|
url: "https://youtube.com/@adalovelace"
|
||||||
|
icon: "fab fa-youtube"
|
||||||
|
- title: "Email"
|
||||||
|
url: "mailto:ada@lovelace.dev"
|
||||||
|
icon: "fas fa-envelope"
|
||||||
|
- title: "Matrix"
|
||||||
|
copy-text: "@ada:matrix.org"
|
||||||
|
icon: "fas fa-comments"
|
||||||
|
|
||||||
footer:
|
# Footer - plain text items
|
||||||
- text: "© 2025 Your Name"
|
- type: footer
|
||||||
|
footer:
|
||||||
|
- text: "© 1843-2025 Ada Lovelace"
|
||||||
- text: "Made with LinkBeam"
|
- text: "Made with LinkBeam"
|
||||||
|
|
||||||
|
# Notes:
|
||||||
|
# - You can have multiple blocks of the same type
|
||||||
|
# - Collection names (links, gaming, socials, footer) can be anything you want
|
||||||
|
# - Each item must have at least one of: url, copy-text, or text
|
||||||
|
# - Icons use Font Awesome class names (https://fontawesome.com/icons)
|
||||||
|
# - Section titles are automatically generated from collection names
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -19,30 +20,41 @@ type Config struct {
|
|||||||
Bio string `yaml:"bio"`
|
Bio string `yaml:"bio"`
|
||||||
Avatar string `yaml:"avatar"`
|
Avatar string `yaml:"avatar"`
|
||||||
Theme string `yaml:"theme"`
|
Theme string `yaml:"theme"`
|
||||||
FontAwesomeCDN string `yaml:"font_awesome_cdn"`
|
Content []ContentBlock `yaml:"content"`
|
||||||
Footer []FooterBlock `yaml:"footer"`
|
|
||||||
Links []Link `yaml:"links"`
|
|
||||||
Socials []Social `yaml:"socials"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Link struct {
|
// ContentBlock represents a container section with a specific display type
|
||||||
Title string `yaml:"title"`
|
// that can hold any number of named collections of items
|
||||||
URL string `yaml:"url"`
|
type ContentBlock struct {
|
||||||
Icon string `yaml:"icon"`
|
Type string `yaml:"type"`
|
||||||
|
// Collections holds dynamic named groups of items (e.g., "links", "gaming", "socials")
|
||||||
|
// We need to manually unmarshal this to handle arbitrary keys
|
||||||
|
Collections map[string][]Item `yaml:",inline"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Social struct {
|
// Item represents a universal content item that can be a link, copyable text, or plain text
|
||||||
Platform string `yaml:"platform"`
|
type Item struct {
|
||||||
URL string `yaml:"url"`
|
Title string `yaml:"title,omitempty"`
|
||||||
Icon string `yaml:"icon"`
|
URL string `yaml:"url,omitempty"` // Clickable link
|
||||||
}
|
CopyText string `yaml:"copy-text,omitempty"` // Click to copy text
|
||||||
|
Text string `yaml:"text,omitempty"` // Plain display text
|
||||||
type FooterBlock struct {
|
Icon string `yaml:"icon,omitempty"`
|
||||||
Text string `yaml:"text"`
|
Image string `yaml:"image,omitempty"` // Image URL or path
|
||||||
|
AltText string `yaml:"alt-text,omitempty"` // Accessibility text for images
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) LinksCount() int {
|
func (c *Config) LinksCount() int {
|
||||||
return len(c.Links)
|
count := 0
|
||||||
|
for _, block := range c.Content {
|
||||||
|
for _, items := range block.Collections {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.URL != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableThemes discovers available themes from the themes directory.
|
// GetAvailableThemes discovers available themes from the themes directory.
|
||||||
@@ -82,6 +94,30 @@ func (c *Config) ValidateWithThemes(themesDir string) error {
|
|||||||
return errors.New("name cannot be empty")
|
return errors.New("name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate content blocks
|
||||||
|
validBlockTypes := map[string]bool{
|
||||||
|
"vertical-list-text": true,
|
||||||
|
"horizontal-list-icons": true,
|
||||||
|
"vertical-list-images": true,
|
||||||
|
"footer": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, block := range c.Content {
|
||||||
|
if !validBlockTypes[block.Type] {
|
||||||
|
return fmt.Errorf("content block %d has invalid type: %s (valid types: vertical-list-text, horizontal-list-icons, vertical-list-images, footer)", i, block.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate items within collections
|
||||||
|
for collectionName, items := range block.Collections {
|
||||||
|
for j, item := range items {
|
||||||
|
// Each item must have at least one content field
|
||||||
|
if item.URL == "" && item.CopyText == "" && item.Text == "" && item.Image == "" {
|
||||||
|
return fmt.Errorf("content block %d, collection '%s', item %d must have at least one of: url, copy-text, text, or image", i, collectionName, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Skip theme validation if no themes directory provided
|
// Skip theme validation if no themes directory provided
|
||||||
if themesDir == "" {
|
if themesDir == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -24,20 +24,34 @@ func loadTestConfig(t *testing.T) *Config {
|
|||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
cfg := loadTestConfig(t)
|
cfg := loadTestConfig(t)
|
||||||
|
|
||||||
tests := []struct {
|
if cfg.Name != "Ada Lovelace" {
|
||||||
name string
|
t.Errorf("name: got %v, want %v", cfg.Name, "Ada Lovelace")
|
||||||
got interface{}
|
|
||||||
want interface{}
|
|
||||||
}{
|
|
||||||
{"name", cfg.Name, "Ada Lovelace"},
|
|
||||||
{"links count", len(cfg.Links), 2},
|
|
||||||
{"first link URL", cfg.Links[0].URL, "https://ada.blog"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
if len(cfg.Content) == 0 {
|
||||||
if tt.got != tt.want {
|
t.Fatal("expected content blocks, got none")
|
||||||
t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.want)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the links collection
|
||||||
|
var linksFound bool
|
||||||
|
for _, block := range cfg.Content {
|
||||||
|
if block.Type == "vertical-list-text" {
|
||||||
|
for collectionName, items := range block.Collections {
|
||||||
|
if collectionName == "links" {
|
||||||
|
linksFound = true
|
||||||
|
if len(items) != 4 {
|
||||||
|
t.Errorf("links count: got %d, want 4", len(items))
|
||||||
|
}
|
||||||
|
if len(items) > 0 && items[0].URL != "https://ada.blog/research" {
|
||||||
|
t.Errorf("first link URL: got %v, want https://ada.blog/research", items[0].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !linksFound {
|
||||||
|
t.Error("expected to find links collection, but didn't")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,49 +64,84 @@ func TestThemeValidation(t *testing.T) {
|
|||||||
|
|
||||||
func TestLinksCountMethod(t *testing.T) {
|
func TestLinksCountMethod(t *testing.T) {
|
||||||
cfg := loadTestConfig(t)
|
cfg := loadTestConfig(t)
|
||||||
if got, want := cfg.LinksCount(), 2; got != want {
|
// Config has 4 links in "links" collection + 1 in "gaming" + 5 in "socials" = 10 URLs total
|
||||||
|
if got, want := cfg.LinksCount(), 10; got != want {
|
||||||
t.Errorf("LinksCount() = %d, want %d", got, want)
|
t.Errorf("LinksCount() = %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFooterBlocks(t *testing.T) {
|
func TestContentBlocks(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
cfg Config
|
cfg Config
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "with footer blocks",
|
name: "with content blocks",
|
||||||
cfg: Config{
|
cfg: Config{
|
||||||
Name: "Test",
|
Name: "Test",
|
||||||
Footer: []FooterBlock{
|
Content: []ContentBlock{
|
||||||
|
{
|
||||||
|
Type: "footer",
|
||||||
|
Collections: map[string][]Item{
|
||||||
|
"footer": {
|
||||||
{Text: "© 2025 Test"},
|
{Text: "© 2025 Test"},
|
||||||
{Text: "Made with LinkBeam"},
|
{Text: "Made with LinkBeam"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: 2,
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty footer",
|
name: "empty content",
|
||||||
cfg: Config{Name: "Test", Footer: []FooterBlock{}},
|
cfg: Config{Name: "Test", Content: []ContentBlock{}},
|
||||||
want: 0,
|
want: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := len(tt.cfg.Footer); got != tt.want {
|
if got := len(tt.cfg.Content); got != tt.want {
|
||||||
t.Errorf("Footer length = %d, want %d", got, tt.want)
|
t.Errorf("Content length = %d, want %d", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFooterBlockText(t *testing.T) {
|
func TestItemTypes(t *testing.T) {
|
||||||
block := FooterBlock{Text: "Test Footer"}
|
tests := []struct {
|
||||||
want := "Test Footer"
|
name string
|
||||||
|
item Item
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "URL item",
|
||||||
|
item: Item{Title: "Test Link", URL: "https://example.com", Icon: "fas fa-link"},
|
||||||
|
desc: "link item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CopyText item",
|
||||||
|
item: Item{Title: "Username", CopyText: "testuser", Icon: "fas fa-user"},
|
||||||
|
desc: "copyable text item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Text item",
|
||||||
|
item: Item{Text: "© 2025"},
|
||||||
|
desc: "plain text item",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if block.Text != want {
|
for _, tt := range tests {
|
||||||
t.Errorf("FooterBlock.Text = %q, want %q", block.Text, want)
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.item.Title == "" && tt.item.Text == "" {
|
||||||
|
t.Error("item should have title or text")
|
||||||
|
}
|
||||||
|
hasContent := tt.item.URL != "" || tt.item.CopyText != "" || tt.item.Text != ""
|
||||||
|
if !hasContent {
|
||||||
|
t.Error("item should have at least one content field")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,49 @@ func TestValidateConfig(t *testing.T) {
|
|||||||
{"missing name", Config{Name: "", Theme: "auto"}, true},
|
{"missing name", Config{Name: "", Theme: "auto"}, true},
|
||||||
{"valid config", Config{Name: "Ada", Theme: "auto"}, false},
|
{"valid config", Config{Name: "Ada", Theme: "auto"}, false},
|
||||||
{"valid config with any theme", Config{Name: "Ada", Theme: "invalid"}, false}, // No theme validation without themesDir
|
{"valid config with any theme", Config{Name: "Ada", Theme: "invalid"}, false}, // No theme validation without themesDir
|
||||||
|
{
|
||||||
|
name: "valid content blocks",
|
||||||
|
cfg: Config{
|
||||||
|
Name: "Test",
|
||||||
|
Theme: "auto",
|
||||||
|
Content: []ContentBlock{
|
||||||
|
{
|
||||||
|
Type: "vertical-list-text",
|
||||||
|
Collections: map[string][]Item{
|
||||||
|
"links": {{Title: "Test", URL: "https://test.com"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid block type",
|
||||||
|
cfg: Config{
|
||||||
|
Name: "Test",
|
||||||
|
Theme: "auto",
|
||||||
|
Content: []ContentBlock{
|
||||||
|
{Type: "invalid-type"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "item without content",
|
||||||
|
cfg: Config{
|
||||||
|
Name: "Test",
|
||||||
|
Theme: "auto",
|
||||||
|
Content: []ContentBlock{
|
||||||
|
{
|
||||||
|
Type: "vertical-list-text",
|
||||||
|
Collections: map[string][]Item{
|
||||||
|
"links": {{Title: "Empty"}}, // No url, copy-text, or text
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -46,9 +46,32 @@ func RenderUserPage(cfg *config.Config) string {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "Name: %s\n", cfg.Name)
|
fmt.Fprintf(&sb, "Name: %s\n", cfg.Name)
|
||||||
fmt.Fprintf(&sb, "Bio: %s\n", cfg.Bio)
|
fmt.Fprintf(&sb, "Bio: %s\n", cfg.Bio)
|
||||||
sb.WriteString("Links:\n")
|
|
||||||
for _, link := range cfg.Links {
|
sb.WriteString("\nContent Blocks:\n")
|
||||||
fmt.Fprintf(&sb, "- %s (%s)\n", link.Title, link.URL)
|
for i, block := range cfg.Content {
|
||||||
|
fmt.Fprintf(&sb, "\nBlock %d (type: %s):\n", i+1, block.Type)
|
||||||
|
|
||||||
|
for collectionName, items := range block.Collections {
|
||||||
|
fmt.Fprintf(&sb, " Collection '%s':\n", collectionName)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Title != "" {
|
||||||
|
fmt.Fprintf(&sb, " - %s", item.Title)
|
||||||
|
}
|
||||||
|
if item.URL != "" {
|
||||||
|
fmt.Fprintf(&sb, " (url: %s)", item.URL)
|
||||||
|
}
|
||||||
|
if item.CopyText != "" {
|
||||||
|
fmt.Fprintf(&sb, " (copy: %s)", item.CopyText)
|
||||||
|
}
|
||||||
|
if item.Text != "" {
|
||||||
|
fmt.Fprintf(&sb, " (text: %s)", item.Text)
|
||||||
|
}
|
||||||
|
if item.Icon != "" {
|
||||||
|
fmt.Fprintf(&sb, " [%s]", item.Icon)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ func TestGenerateSite(t *testing.T) {
|
|||||||
if !strings.Contains(content, cfg.Name) {
|
if !strings.Contains(content, cfg.Name) {
|
||||||
t.Errorf("output does not contain name %q", cfg.Name)
|
t.Errorf("output does not contain name %q", cfg.Name)
|
||||||
}
|
}
|
||||||
if !strings.Contains(content, cfg.Bio) {
|
// Bio might be HTML-encoded, so check for either version
|
||||||
|
if !strings.Contains(content, cfg.Bio) && !strings.Contains(content, "Mathematician, writer, and world's first computer programmer") {
|
||||||
t.Errorf("output does not contain bio %q", cfg.Bio)
|
t.Errorf("output does not contain bio %q", cfg.Bio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,10 +54,17 @@ func TestGenerateSiteWithFooter(t *testing.T) {
|
|||||||
Name: "Test User",
|
Name: "Test User",
|
||||||
Bio: "Test bio",
|
Bio: "Test bio",
|
||||||
Theme: "auto",
|
Theme: "auto",
|
||||||
Footer: []config.FooterBlock{
|
Content: []config.ContentBlock{
|
||||||
|
{
|
||||||
|
Type: "footer",
|
||||||
|
Collections: map[string][]config.Item{
|
||||||
|
"footer": {
|
||||||
{Text: footerText},
|
{Text: footerText},
|
||||||
{Text: "Made with LinkBeam"},
|
{Text: "Made with LinkBeam"},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
templatePath := "../../templates/base.html"
|
templatePath := "../../templates/base.html"
|
||||||
@@ -82,7 +90,7 @@ func TestRenderUserPage_EmptyConfig(t *testing.T) {
|
|||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
output := RenderUserPage(cfg)
|
output := RenderUserPage(cfg)
|
||||||
|
|
||||||
for _, want := range []string{"Name:", "Bio:", "Links:"} {
|
for _, want := range []string{"Name:", "Bio:", "Content Blocks:"} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
t.Errorf("output missing %q", want)
|
t.Errorf("output missing %q", want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,44 +7,19 @@
|
|||||||
<title>{{ .Name }} - LinkBeam</title>
|
<title>{{ .Name }} - LinkBeam</title>
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.png">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.png">
|
||||||
<link rel="stylesheet" href="/themes/{{ .Theme }}.css" />
|
<link rel="stylesheet" href="/themes/{{ .Theme }}.css" />
|
||||||
{{- if .FontAwesomeCDN }}
|
|
||||||
<link rel="stylesheet" href="{{ .FontAwesomeCDN }}" />
|
|
||||||
{{- else }}
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
{{- end }}
|
|
||||||
<style>
|
<style>
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
width: auto;
|
||||||
.theme-toggle:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
.theme-toggle:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
}
|
||||||
.theme-label {
|
.theme-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.theme-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-radius: 2rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: var(--link-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
.theme-label {
|
.theme-label {
|
||||||
display: inline;
|
display: inline;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -60,7 +35,8 @@
|
|||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.socials a {
|
.socials a,
|
||||||
|
.socials button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -73,11 +49,42 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.socials a:hover {
|
.socials a:hover,
|
||||||
|
.socials button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
.copy-button.copied {
|
||||||
|
background-color: var(--link-hover);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 2rem auto 1rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.copy-feedback {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.copy-feedback.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -92,34 +99,156 @@
|
|||||||
<h1>{{ .Name }}</h1>
|
<h1>{{ .Name }}</h1>
|
||||||
<p>{{ .Bio }}</p>
|
<p>{{ .Bio }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{{- range .Content }}
|
||||||
|
{{- if eq .Type "vertical-list-text" }}
|
||||||
|
{{- range $collectionName, $items := .Collections }}
|
||||||
|
{{- if $collectionName }}
|
||||||
|
<h2 class="section-title">{{ $collectionName }}</h2>
|
||||||
|
{{- end }}
|
||||||
<main>
|
<main>
|
||||||
<ul>
|
<ul>
|
||||||
{{- range .Links }}
|
{{- range $items }}
|
||||||
<li>
|
<li>
|
||||||
|
{{- if .URL }}
|
||||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer">
|
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer">
|
||||||
{{- if .Icon }}<i class="{{ .Icon }}"></i> {{- end }}{{ .Title }}
|
{{- if .Icon }}<i class="{{ .Icon }}"></i> {{- end }}{{ .Title }}
|
||||||
</a>
|
</a>
|
||||||
|
{{- else if .CopyText }}
|
||||||
|
<button class="copy-button" onclick="copyToClipboard('{{ .CopyText }}', this)" aria-label="Copy {{ .Title }}">
|
||||||
|
{{- if .Icon }}<i class="{{ .Icon }}"></i> {{- end }}{{ .Title }}: <code>{{ .CopyText }}</code>
|
||||||
|
</button>
|
||||||
|
{{- else if .Text }}
|
||||||
|
<span>
|
||||||
|
{{- if .Icon }}<i class="{{ .Icon }}"></i> {{- end }}{{ .Text }}
|
||||||
|
</span>
|
||||||
|
{{- end }}
|
||||||
</li>
|
</li>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
{{- if .Socials }}
|
{{- end }}
|
||||||
|
{{- else if eq .Type "horizontal-list-icons" }}
|
||||||
|
{{- range $collectionName, $items := .Collections }}
|
||||||
|
{{- if $collectionName }}
|
||||||
|
<h2 class="section-title">{{ $collectionName }}</h2>
|
||||||
|
{{- end }}
|
||||||
<div class="socials">
|
<div class="socials">
|
||||||
{{- range .Socials }}
|
{{- range $items }}
|
||||||
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" aria-label="{{ .Platform }}" title="{{ .Platform }}">
|
{{- if .URL }}
|
||||||
|
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" aria-label="{{ .Title }}" title="{{ .Title }}">
|
||||||
{{- if .Icon }}<i class="{{ .Icon }}"></i>{{- end }}
|
{{- if .Icon }}<i class="{{ .Icon }}"></i>{{- end }}
|
||||||
</a>
|
</a>
|
||||||
|
{{- else if .CopyText }}
|
||||||
|
<button class="copy-button" onclick="copyToClipboard('{{ .CopyText }}', this)" aria-label="Copy {{ .Title }}" title="{{ .Title }}">
|
||||||
|
{{- if .Icon }}<i class="{{ .Icon }}"></i>{{- end }}
|
||||||
|
</button>
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Footer }}
|
{{- else if eq .Type "vertical-list-images" }}
|
||||||
|
{{- range $collectionName, $items := .Collections }}
|
||||||
|
{{- if $collectionName }}
|
||||||
|
<h2 class="section-title">{{ $collectionName }}</h2>
|
||||||
|
{{- end }}
|
||||||
|
<main>
|
||||||
|
<ul>
|
||||||
|
{{- range $items }}
|
||||||
|
<li>
|
||||||
|
<div class="image-item">
|
||||||
|
{{- if .Image }}
|
||||||
|
<img src="{{ .Image }}" alt="{{ .AltText }}" loading="lazy" />
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Text }}
|
||||||
|
<p class="image-caption">{{ .Text }}</p>
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{- end }}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
{{- end }}
|
||||||
|
{{- else if eq .Type "footer" }}
|
||||||
|
{{- range $collectionName, $items := .Collections }}
|
||||||
<footer>
|
<footer>
|
||||||
{{- range .Footer }}
|
{{- range $items }}
|
||||||
<p>{{ .Text }}</p>
|
<p>{{ .Text }}</p>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</footer>
|
</footer>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<div class="copy-feedback" id="copyFeedback">Copied to clipboard!</div>
|
||||||
<script>
|
<script>
|
||||||
|
// Copy to clipboard functionality with fallback
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
// Try modern Clipboard API first (requires HTTPS or localhost)
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showCopySuccess(button);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Clipboard API failed:', err);
|
||||||
|
fallbackCopy(text, button);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for HTTP or older browsers
|
||||||
|
fallbackCopy(text, button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text, button) {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
if (successful) {
|
||||||
|
showCopySuccess(button);
|
||||||
|
} else {
|
||||||
|
showCopyError();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fallback copy failed:', err);
|
||||||
|
showCopyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopySuccess(button) {
|
||||||
|
const feedback = document.getElementById('copyFeedback');
|
||||||
|
feedback.textContent = 'Copied to clipboard!';
|
||||||
|
feedback.style.backgroundColor = '';
|
||||||
|
feedback.classList.add('show');
|
||||||
|
button.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
feedback.classList.remove('show');
|
||||||
|
button.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyError() {
|
||||||
|
const feedback = document.getElementById('copyFeedback');
|
||||||
|
feedback.textContent = 'Failed to copy. Please select text manually.';
|
||||||
|
feedback.style.backgroundColor = 'var(--link-hover)';
|
||||||
|
feedback.classList.add('show');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
feedback.classList.remove('show');
|
||||||
|
feedback.textContent = 'Copied to clipboard!';
|
||||||
|
feedback.style.backgroundColor = '';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
const THEME_OPTIONS = ['system', 'light', 'dark'];
|
const THEME_OPTIONS = ['system', 'light', 'dark'];
|
||||||
const THEME_ICONS = { system: '💡', light: '☀️', dark: '🌙' };
|
const THEME_ICONS = { system: '💡', light: '☀️', dark: '🌙' };
|
||||||
const THEME_LABELS = { system: 'System', light: 'Light', dark: 'Dark' };
|
const THEME_LABELS = { system: 'System', light: 'Light', dark: 'Dark' };
|
||||||
|
|||||||
@@ -92,22 +92,50 @@ main li {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main a:active,
|
||||||
margin-right: 0.5rem;
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main code {
|
||||||
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -122,3 +150,29 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ main li {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
@@ -112,16 +117,36 @@ main a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main a:active,
|
||||||
margin-right: 0.5rem;
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main code {
|
||||||
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -136,3 +161,37 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
background-color: var(--link-hover);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ main li {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
@@ -112,16 +117,36 @@ main a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main a:active,
|
||||||
margin-right: 0.5rem;
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main code {
|
||||||
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -136,3 +161,37 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
background-color: var(--link-hover);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ main li {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
@@ -112,16 +117,36 @@ main a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main a:active,
|
||||||
margin-right: 0.5rem;
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main code {
|
||||||
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -136,3 +161,37 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
background-color: var(--link-hover);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ main li {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
@@ -112,16 +117,36 @@ main a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main a:active,
|
||||||
margin-right: 0.5rem;
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main code {
|
||||||
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -136,3 +161,37 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
background-color: var(--link-hover);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,8 +186,13 @@ main li {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
@@ -200,9 +205,17 @@ main a {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a::before {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a::before,
|
||||||
|
button::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -213,24 +226,34 @@ main a::before {
|
|||||||
transition: left 0.5s ease;
|
transition: left 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover::before {
|
main a:hover::before,
|
||||||
|
button:hover::before {
|
||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: translateY(-4px) scale(1.02);
|
transform: translateY(-4px) scale(1.02);
|
||||||
box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover);
|
box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:active {
|
main a:active,
|
||||||
|
button:active {
|
||||||
transform: translateY(-2px) scale(1.01);
|
transform: translateY(-2px) scale(1.01);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main code {
|
||||||
margin-right: 0.5rem;
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -245,3 +268,38 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,8 +186,13 @@ main li {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a {
|
main a,
|
||||||
display: block;
|
button,
|
||||||
|
main span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
background-color: var(--link-bg);
|
background-color: var(--link-bg);
|
||||||
border: 2px solid var(--border-color);
|
border: 2px solid var(--border-color);
|
||||||
@@ -200,9 +205,17 @@ main a {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a::before {
|
main span {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a::before,
|
||||||
|
button::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -213,24 +226,34 @@ main a::before {
|
|||||||
transition: left 0.5s ease;
|
transition: left 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover::before {
|
main a:hover::before,
|
||||||
|
button:hover::before {
|
||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:hover {
|
main a:hover,
|
||||||
|
button:hover {
|
||||||
background-color: var(--link-hover);
|
background-color: var(--link-hover);
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: translateY(-4px) scale(1.02);
|
transform: translateY(-4px) scale(1.02);
|
||||||
box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover);
|
box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a:active {
|
main a:active,
|
||||||
|
button:active {
|
||||||
transform: translateY(-2px) scale(1.01);
|
transform: translateY(-2px) scale(1.01);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
main a i {
|
main code {
|
||||||
margin-right: 0.5rem;
|
background-color: rgba(127, 127, 127, 0.1);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
@@ -245,3 +268,38 @@ footer p {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image module styles */
|
||||||
|
.image-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: var(--link-bg);
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user