more-features #2

Merged
nett00n merged 2 commits from more-features into main 2025-11-16 10:17:07 +01:00
17 changed files with 935 additions and 183 deletions

7
.gitignore vendored
View File

@@ -38,3 +38,10 @@ config.yaml
# Pre-commit
.pre-commit-cache/
static/
!static/favicon.png
!static/logo.png
!static/ada-256x256.png
!.gitignore

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -2,35 +2,86 @@
# Example configuration for LinkBeam
---
name: "Your Name"
bio: "A short bio about yourself"
avatar: "static/logo.png" # Can be a local path or URL (http://... or https://...)
theme: "auto"
links:
- title: "My Website"
url: "https://yourwebsite.com"
icon: "fas fa-globe"
- title: "Blog"
url: "https://yourblog.com"
icon: "fas fa-blog"
- title: "GitHub"
url: "https://github.com/yourusername"
name: "Ada Lovelace"
bio: "Mathematician, writer, and world's first computer programmer"
avatar: "static/ada-256x256.png" # Can be a local path or URL (http://... or https://...)
theme: "auto" # Options: auto, nord, gruvbox, catppuccin-mocha, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato
# Content blocks - flexible structure with named collections
# Each block can contain any named collection of items
# Item types: url (clickable link), copy-text (click to copy), text (plain display)
content:
# Vertical list with text labels - good for primary links
- type: vertical-list-text
links:
- 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"
- title: "Portfolio"
url: "https://portfolio.com"
icon: "fas fa-briefcase"
- title: "Speaking Events"
url: "https://ada.blog/events"
icon: "fas fa-calendar"
socials:
- platform: "Twitter"
url: "https://twitter.com/yourusername"
# Another vertical list - gaming profiles with copy-text
- type: vertical-list-text
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"
- platform: "LinkedIn"
url: "https://linkedin.com/in/yourusername"
icon: "fab fa-linkedin"
- platform: "Instagram"
url: "https://instagram.com/yourusername"
icon: "fab fa-instagram"
- title: "LinkedIn"
url: "https://linkedin.com/in/ada-lovelace"
icon: "fab fa-linkedin-in"
- title: "Mastodon"
url: "https://mastodon.social/@ada"
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:
- text: "© 2025 Your Name"
# Footer - plain text items
- type: footer
footer:
- text: "© 1843-2025 Ada Lovelace"
- 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

View File

@@ -10,6 +10,7 @@ package config
import (
"errors"
"fmt"
"os"
"strings"
)
@@ -19,30 +20,41 @@ type Config struct {
Bio string `yaml:"bio"`
Avatar string `yaml:"avatar"`
Theme string `yaml:"theme"`
FontAwesomeCDN string `yaml:"font_awesome_cdn"`
Footer []FooterBlock `yaml:"footer"`
Links []Link `yaml:"links"`
Socials []Social `yaml:"socials"`
Content []ContentBlock `yaml:"content"`
}
type Link struct {
Title string `yaml:"title"`
URL string `yaml:"url"`
Icon string `yaml:"icon"`
// ContentBlock represents a container section with a specific display type
// that can hold any number of named collections of items
type ContentBlock struct {
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 {
Platform string `yaml:"platform"`
URL string `yaml:"url"`
Icon string `yaml:"icon"`
}
type FooterBlock struct {
Text string `yaml:"text"`
// Item represents a universal content item that can be a link, copyable text, or plain text
type Item struct {
Title string `yaml:"title,omitempty"`
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
Icon string `yaml:"icon,omitempty"`
Image string `yaml:"image,omitempty"` // Image URL or path
AltText string `yaml:"alt-text,omitempty"` // Accessibility text for images
}
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.
@@ -82,6 +94,30 @@ func (c *Config) ValidateWithThemes(themesDir string) error {
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
if themesDir == "" {
return nil

View File

@@ -24,20 +24,34 @@ func loadTestConfig(t *testing.T) *Config {
func TestLoad(t *testing.T) {
cfg := loadTestConfig(t)
tests := []struct {
name string
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"},
if cfg.Name != "Ada Lovelace" {
t.Errorf("name: got %v, want %v", cfg.Name, "Ada Lovelace")
}
for _, tt := range tests {
if tt.got != tt.want {
t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.want)
if len(cfg.Content) == 0 {
t.Fatal("expected content blocks, got none")
}
// 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) {
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)
}
}
func TestFooterBlocks(t *testing.T) {
func TestContentBlocks(t *testing.T) {
tests := []struct {
name string
cfg Config
want int
}{
{
name: "with footer blocks",
name: "with content blocks",
cfg: Config{
Name: "Test",
Footer: []FooterBlock{
Content: []ContentBlock{
{
Type: "footer",
Collections: map[string][]Item{
"footer": {
{Text: "© 2025 Test"},
{Text: "Made with LinkBeam"},
},
},
want: 2,
},
},
},
want: 1,
},
{
name: "empty footer",
cfg: Config{Name: "Test", Footer: []FooterBlock{}},
name: "empty content",
cfg: Config{Name: "Test", Content: []ContentBlock{}},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := len(tt.cfg.Footer); got != tt.want {
t.Errorf("Footer length = %d, want %d", got, tt.want)
if got := len(tt.cfg.Content); got != tt.want {
t.Errorf("Content length = %d, want %d", got, tt.want)
}
})
}
}
func TestFooterBlockText(t *testing.T) {
block := FooterBlock{Text: "Test Footer"}
want := "Test Footer"
func TestItemTypes(t *testing.T) {
tests := []struct {
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 {
t.Errorf("FooterBlock.Text = %q, want %q", block.Text, want)
for _, tt := range tests {
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")
}
})
}
}

View File

@@ -23,6 +23,49 @@ func TestValidateConfig(t *testing.T) {
{"missing name", Config{Name: "", Theme: "auto"}, true},
{"valid config", Config{Name: "Ada", Theme: "auto"}, false},
{"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 {

View File

@@ -46,9 +46,32 @@ func RenderUserPage(cfg *config.Config) string {
var sb strings.Builder
fmt.Fprintf(&sb, "Name: %s\n", cfg.Name)
fmt.Fprintf(&sb, "Bio: %s\n", cfg.Bio)
sb.WriteString("Links:\n")
for _, link := range cfg.Links {
fmt.Fprintf(&sb, "- %s (%s)\n", link.Title, link.URL)
sb.WriteString("\nContent Blocks:\n")
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()
}

View File

@@ -39,7 +39,8 @@ func TestGenerateSite(t *testing.T) {
if !strings.Contains(content, 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)
}
}
@@ -53,10 +54,17 @@ func TestGenerateSiteWithFooter(t *testing.T) {
Name: "Test User",
Bio: "Test bio",
Theme: "auto",
Footer: []config.FooterBlock{
Content: []config.ContentBlock{
{
Type: "footer",
Collections: map[string][]config.Item{
"footer": {
{Text: footerText},
{Text: "Made with LinkBeam"},
},
},
},
},
}
templatePath := "../../templates/base.html"
@@ -82,7 +90,7 @@ func TestRenderUserPage_EmptyConfig(t *testing.T) {
cfg := &config.Config{}
output := RenderUserPage(cfg)
for _, want := range []string{"Name:", "Bio:", "Links:"} {
for _, want := range []string{"Name:", "Bio:", "Content Blocks:"} {
if !strings.Contains(output, want) {
t.Errorf("output missing %q", want)
}

BIN
static/ada-256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -7,44 +7,19 @@
<title>{{ .Name }} - LinkBeam</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.png">
<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" />
{{- end }}
<style>
.theme-toggle {
position: fixed;
top: 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;
}
.theme-toggle:hover {
transform: scale(1.1);
}
.theme-toggle:active {
transform: scale(0.95);
width: auto;
}
.theme-label {
display: none;
}
@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 {
display: inline;
font-size: 0.9rem;
@@ -60,7 +35,8 @@
margin: 2rem auto;
padding: 1rem;
}
.socials a {
.socials a,
.socials button {
display: flex;
align-items: center;
justify-content: center;
@@ -73,11 +49,42 @@
text-decoration: none;
font-size: 1.5rem;
transition: all 0.2s ease;
cursor: pointer;
padding: 0;
}
.socials a:hover {
.socials a:hover,
.socials button:hover {
background-color: var(--link-hover);
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>
</head>
<body>
@@ -92,34 +99,156 @@
<h1>{{ .Name }}</h1>
<p>{{ .Bio }}</p>
</header>
{{- range .Content }}
{{- if eq .Type "vertical-list-text" }}
{{- range $collectionName, $items := .Collections }}
{{- if $collectionName }}
<h2 class="section-title">{{ $collectionName }}</h2>
{{- end }}
<main>
<ul>
{{- range .Links }}
{{- range $items }}
<li>
{{- if .URL }}
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer">
{{- if .Icon }}<i class="{{ .Icon }}"></i> {{- end }}{{ .Title }}
</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>
{{- end }}
</ul>
</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">
{{- range .Socials }}
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" aria-label="{{ .Platform }}" title="{{ .Platform }}">
{{- range $items }}
{{- if .URL }}
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" aria-label="{{ .Title }}" title="{{ .Title }}">
{{- if .Icon }}<i class="{{ .Icon }}"></i>{{- end }}
</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 }}
</div>
{{- 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>
{{- range .Footer }}
{{- range $items }}
<p>{{ .Text }}</p>
{{- end }}
</footer>
{{- end }}
{{- end }}
{{- end }}
<div class="copy-feedback" id="copyFeedback">Copied to clipboard!</div>
<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_ICONS = { system: '💡', light: '☀️', dark: '🌙' };
const THEME_LABELS = { system: 'System', light: 'Light', dark: 'Dark' };

View File

@@ -92,22 +92,50 @@ main li {
margin-bottom: 0.5rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
background-color: var(--link-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
text-decoration: none;
color: var(--text-color);
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);
transform: translateY(-2px);
}
main a i {
margin-right: 0.5rem;
main a:active,
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 {
@@ -122,3 +150,29 @@ footer p {
color: var(--text-secondary);
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;
}

View File

@@ -101,8 +101,13 @@ main li {
margin-bottom: 1rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
background-color: var(--link-bg);
border: 2px solid var(--border-color);
@@ -112,16 +117,36 @@ main a {
font-weight: 600;
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);
border-color: var(--accent-color);
transform: translateY(-2px);
}
main a i {
margin-right: 0.5rem;
main a:active,
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 {
@@ -136,3 +161,37 @@ footer p {
color: var(--text-secondary);
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;
}

View File

@@ -101,8 +101,13 @@ main li {
margin-bottom: 1rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
background-color: var(--link-bg);
border: 2px solid var(--border-color);
@@ -112,16 +117,36 @@ main a {
font-weight: 600;
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);
border-color: var(--accent-color);
transform: translateY(-2px);
}
main a i {
margin-right: 0.5rem;
main a:active,
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 {
@@ -136,3 +161,37 @@ footer p {
color: var(--text-secondary);
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;
}

View File

@@ -101,8 +101,13 @@ main li {
margin-bottom: 1rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
background-color: var(--link-bg);
border: 2px solid var(--border-color);
@@ -112,16 +117,36 @@ main a {
font-weight: 600;
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);
border-color: var(--accent-color);
transform: translateY(-2px);
}
main a i {
margin-right: 0.5rem;
main a:active,
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 {
@@ -136,3 +161,37 @@ footer p {
color: var(--text-secondary);
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;
}

View File

@@ -101,8 +101,13 @@ main li {
margin-bottom: 1rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
background-color: var(--link-bg);
border: 2px solid var(--border-color);
@@ -112,16 +117,36 @@ main a {
font-weight: 600;
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);
border-color: var(--accent-color);
transform: translateY(-2px);
}
main a i {
margin-right: 0.5rem;
main a:active,
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 {
@@ -136,3 +161,37 @@ footer p {
color: var(--text-secondary);
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;
}

View File

@@ -186,8 +186,13 @@ main li {
margin-bottom: 1rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.25rem 1.5rem;
background-color: var(--link-bg);
border: 2px solid var(--border-color);
@@ -200,9 +205,17 @@ main a {
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
cursor: pointer;
width: 100%;
font: inherit;
}
main a::before {
main span {
cursor: default;
}
main a::before,
button::before {
content: '';
position: absolute;
top: 0;
@@ -213,24 +226,34 @@ main a::before {
transition: left 0.5s ease;
}
main a:hover::before {
main a:hover::before,
button:hover::before {
left: 100%;
}
main a:hover {
main a:hover,
button:hover {
background-color: var(--link-hover);
border-color: var(--accent-color);
transform: translateY(-4px) scale(1.02);
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);
box-shadow: var(--shadow-sm);
}
main a i {
margin-right: 0.5rem;
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 {
@@ -245,3 +268,38 @@ footer p {
color: var(--text-secondary);
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;
}

View File

@@ -186,8 +186,13 @@ main li {
margin-bottom: 1rem;
}
main a {
display: block;
main a,
button,
main span {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.25rem 1.5rem;
background-color: var(--link-bg);
border: 2px solid var(--border-color);
@@ -200,9 +205,17 @@ main a {
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
cursor: pointer;
width: 100%;
font: inherit;
}
main a::before {
main span {
cursor: default;
}
main a::before,
button::before {
content: '';
position: absolute;
top: 0;
@@ -213,24 +226,34 @@ main a::before {
transition: left 0.5s ease;
}
main a:hover::before {
main a:hover::before,
button:hover::before {
left: 100%;
}
main a:hover {
main a:hover,
button:hover {
background-color: var(--link-hover);
border-color: var(--accent-color);
transform: translateY(-4px) scale(1.02);
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);
box-shadow: var(--shadow-sm);
}
main a i {
margin-right: 0.5rem;
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 {
@@ -245,3 +268,38 @@ footer p {
color: var(--text-secondary);
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;
}