diff --git a/.webmentions-sent b/.webmentions-sent new file mode 100644 index 0000000..5493921 --- /dev/null +++ b/.webmentions-sent @@ -0,0 +1 @@ +https://ritual.sh/blog/2026-01-06-week-3-bit-chilly-out/|https://enclose.horse/ diff --git a/GRAPHS.md b/GRAPHS.md new file mode 100644 index 0000000..b2dc1de --- /dev/null +++ b/GRAPHS.md @@ -0,0 +1,262 @@ +# Graph Shortcode Documentation + +This Hugo site includes a custom shortcode for creating terminal-styled graphs that match the hacker green aesthetic. + +## Features + +- **Server-side processing**: Data is processed when Hugo builds the site +- **Terminal styling**: Automatic green color scheme matching the site theme +- **Multiple chart types**: Line, bar, pie, doughnut, radar, and more +- **Responsive**: Adapts to different screen sizes +- **Customizable**: Override colors and styling as needed + +## Basic Usage + +The shortcode supports two modes: +1. **Inline JSON data** - Embed data directly in your markdown +2. **CSV file data** - Load data from a CSV file in your page bundle + +### CSV File Mode (Recommended) + +Place your CSV file in the same directory as your blog post's `index.md`: + +```markdown +{{< graph id="my-chart" type="line" title="My Data" height="500" csv="data.csv" labelColumn="Date" dataColumn="Value" dateFormat="2006-01" >}} +{{< /graph >}} +``` + +**CSV Parameters:** +- `csv` - Filename of the CSV in the page bundle (required for CSV mode) +- `labelColumn` - Column name for x-axis labels (default: "Month") +- `dataColumn` - Column name for y-axis data (default: "Questions") +- `dateFormat` - Go date format for parsing timestamps (default: "2006-01") +- `skipRows` - Number of rows to skip from the start (default: 0) +- `maxRows` - Maximum number of rows to display (default: 0 = all) + +**Example CSV file** (`data.csv`): +```csv +Date,Value +2024-01,100 +2024-02,150 +2024-03,175 +``` + +### Inline JSON Mode + +### Line Chart + +```markdown +{{< graph id="my-chart" type="line" title="My Data" height="400" >}} +{ + "labels": ["Jan", "Feb", "Mar", "Apr", "May"], + "datasets": [{ + "label": "Sales", + "data": [12, 19, 3, 5, 2] + }] +} +{{< /graph >}} +``` + +### Bar Chart + +```markdown +{{< graph id="bar-example" type="bar" title="Monthly Revenue" height="350" >}} +{ + "labels": ["Q1", "Q2", "Q3", "Q4"], + "datasets": [{ + "label": "Revenue ($k)", + "data": [65, 59, 80, 81] + }] +} +{{< /graph >}} +``` + +### Multiple Datasets + +```markdown +{{< graph id="multi-line" type="line" title="Comparison" height="400" >}} +{ + "labels": ["2020", "2021", "2022", "2023", "2024"], + "datasets": [ + { + "label": "Product A", + "data": [30, 45, 60, 70, 85] + }, + { + "label": "Product B", + "data": [20, 35, 55, 65, 75] + } + ] +} +{{< /graph >}} +``` + +### Pie Chart + +```markdown +{{< graph id="pie-chart" type="pie" title="Market Share" height="400" >}} +{ + "labels": ["Chrome", "Firefox", "Safari", "Edge"], + "datasets": [{ + "data": [65, 15, 12, 8] + }] +} +{{< /graph >}} +``` + +## Parameters + +### Common Parameters + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `id` | No | Auto-generated | Unique identifier for the chart | +| `type` | No | `line` | Chart type: `line`, `bar`, `pie`, `doughnut`, `radar`, `polarArea` | +| `title` | No | Empty | Chart title displayed above the graph | +| `height` | No | `400` | Height of the chart container in pixels | + +### CSV Mode Parameters + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `csv` | Yes (for CSV mode) | - | Filename of CSV in page bundle | +| `labelColumn` | No | `"Month"` | CSV column name for labels (x-axis) | +| `dataColumn` | No | `"Questions"` | CSV column name for data (y-axis) | +| `dateFormat` | No | `"2006-01"` | Go date format string for parsing dates | +| `skipRows` | No | `0` | Number of data rows to skip | +| `maxRows` | No | `0` | Max rows to display (0 = all) | + +## Data Format + +The shortcode expects JSON data in Chart.js format. The basic structure is: + +```json +{ + "labels": ["Label 1", "Label 2", "Label 3"], + "datasets": [{ + "label": "Dataset Name", + "data": [value1, value2, value3] + }] +} +``` + +### Advanced Data Options + +You can override the automatic styling by providing Chart.js dataset properties: + +```markdown +{{< graph id="custom-style" type="line" title="Custom Colors" >}} +{ + "labels": ["A", "B", "C"], + "datasets": [{ + "label": "Custom", + "data": [10, 20, 30], + "borderColor": "rgb(255, 99, 132)", + "backgroundColor": "rgba(255, 99, 132, 0.2)", + "borderWidth": 3 + }] +} +{{< /graph >}} +``` + +## Color Scheme + +The shortcode automatically applies terminal-style colors: + +- **Primary**: `rgb(173, 255, 47)` (greenyellow) +- **Secondary**: `rgb(0, 255, 0)` (green) +- **Tertiary**: `rgb(0, 200, 0)` (darker green) +- **Grid**: `rgba(0, 255, 0, 0.2)` +- **Background**: `rgba(0, 255, 0, 0.1)` + +Multiple datasets automatically cycle through these colors. + +## CSS Classes + +The graph container has the class `.graph-container` with the following variants: + +- `.graph-container.full-width` - Full width graph (extends to edges) +- `.graph-container.compact` - Smaller padding and title + +To use variants, wrap the shortcode in a div: + +```html +
+{{< graph ... >}} +... +{{< /graph >}} +
+``` + +## Examples + +### StackOverflow Decline + +```markdown +{{< graph id="stackoverflow" type="line" title="Stack Overflow Activity" height="400" >}} +{ + "labels": ["2018", "2019", "2020", "2021", "2022", "2023", "2024"], + "datasets": [{ + "label": "Questions (thousands/month)", + "data": [170, 180, 195, 175, 150, 120, 85], + "fill": true + }] +} +{{< /graph >}} +``` + +### Technology Adoption + +```markdown +{{< graph id="tech-adoption" type="bar" title="Framework Adoption (%)" height="350" >}} +{ + "labels": ["React", "Vue", "Angular", "Svelte"], + "datasets": [{ + "label": "2023", + "data": [67, 45, 42, 18] + }, { + "label": "2024", + "data": [71, 48, 38, 25] + }] +} +{{< /graph >}} +``` + +### Response Time Distribution + +```markdown +{{< graph id="response-times" type="doughnut" title="API Response Times" height="400" >}} +{ + "labels": ["< 100ms", "100-500ms", "500ms-1s", "> 1s"], + "datasets": [{ + "data": [45, 35, 15, 5] + }] +} +{{< /graph >}} +``` + +## Tips + +1. **Keep IDs unique**: Each chart on a page needs a unique ID +2. **Data validation**: The shortcode validates JSON at build time - invalid JSON will cause build errors +3. **Responsive**: Charts are responsive by default, but fixed heights work better for consistency +4. **Performance**: Charts are rendered client-side but data is embedded at build time +5. **Testing**: Set `draft: false` and run `hugo server -D` to preview graphs + +## Troubleshooting + +**Graph not showing?** +- Check browser console for JavaScript errors +- Verify Chart.js is loaded (should be in page ``) +- Ensure JSON data is valid +- Check that the ID is unique on the page + +**Styling issues?** +- Graphs inherit the terminal theme automatically +- Custom colors can be added per dataset +- Container styling can be modified in `assets/sass/partials/_graphs.scss` + +**Build errors?** +- Invalid JSON will cause Hugo build failures +- Check for unclosed braces or missing commas +- Ensure the shortcode tags are properly formatted diff --git a/assets/js/adoptables/lavalamp-adoptable.js b/assets/js/adoptables/lavalamp-adoptable.js new file mode 100644 index 0000000..114dc1a --- /dev/null +++ b/assets/js/adoptables/lavalamp-adoptable.js @@ -0,0 +1,386 @@ +(function () { + "use strict"; + + const previewContainers = { + flex: document.getElementById("lavalamp-preview-flex"), + small: document.getElementById("lavalamp-preview-100"), + large: document.getElementById("lavalamp-preview-200"), + }; + const bgColor1Input = document.getElementById("bg-color-1"); + const bgColor2Input = document.getElementById("bg-color-2"); + const blobColor1Input = document.getElementById("blob-color-1"); + const blobColor2Input = document.getElementById("blob-color-2"); + const caseColorInput = document.getElementById("case-color"); + const blobCountInput = document.getElementById("blob-count"); + const speedInput = document.getElementById("speed"); + const blobSizeInput = document.getElementById("blob-size"); + const pixelateInput = document.getElementById("pixelate"); + const pixelSizeInput = document.getElementById("pixel-size"); + const pixelSizeGroup = document.getElementById("pixel-size-group"); + const blobCountValue = document.getElementById("blob-count-value"); + const speedValue = document.getElementById("speed-value"); + const blobSizeValue = document.getElementById("blob-size-value"); + const pixelSizeValue = document.getElementById("pixel-size-value"); + const getCodeBtn = document.getElementById("get-code-btn"); + const embedCodeSection = document.getElementById("embed-code-section"); + const embedCodeDisplay = document.getElementById("embed-code-display"); + const copyCodeBtn = document.getElementById("copy-code-btn"); + const copyStatus = document.getElementById("copy-status"); + + let lampInstances = { + flex: { lampElements: {}, blobs: [] }, + small: { lampElements: {}, blobs: [] }, + large: { lampElements: {}, blobs: [] }, + }; + + // Initialize the preview lamps + function initPreview() { + Object.keys(previewContainers).forEach((key, index) => { + const previewContainer = previewContainers[key]; + const gooFilterId = `goo-preview-${key}`; + + // Create SVG filters + const svg = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + svg.style.position = "absolute"; + svg.style.width = "0"; + svg.style.height = "0"; + const pixelateFilterId = `pixelate-preview-${key}`; + svg.innerHTML = ` + + + + + + + + + + + + + + + `; + + const container = document.createElement("div"); + container.className = "lavalamp-adoptable"; + container.style.width = "100%"; + container.style.height = "100%"; + + const lampCap = document.createElement("div"); + lampCap.className = "lamp-cap"; + + const lampBody = document.createElement("div"); + lampBody.className = "lamp-body"; + + const blobsContainer = document.createElement("div"); + blobsContainer.className = "blobs-container"; + // Initially set the goo filter, will be adjusted after layout + blobsContainer.style.filter = `url(#${gooFilterId})`; + + const lampBase = document.createElement("div"); + lampBase.className = "lamp-base"; + + lampBody.appendChild(blobsContainer); + container.appendChild(svg); + container.appendChild(lampCap); + container.appendChild(lampBody); + container.appendChild(lampBase); + previewContainer.appendChild(container); + + lampInstances[key].lampElements = { + container, + lampCap, + lampBody, + lampBase, + blobsContainer, + svg, + pixelateFilterId, + }; + }); + + // Apply initial styles + updatePreview(); + + // Adjust goo filter for small lamps after layout + adjustGooFilters(); + } + + // Adjust goo filters based on container width (matches embedded script logic) + function adjustGooFilters() { + Object.keys(lampInstances).forEach((key) => { + const instance = lampInstances[key]; + const { lampElements } = instance; + + if (!lampElements.lampBody) return; + + const containerWidth = lampElements.lampBody.offsetWidth; + }); + } + + // Create a blob element for a specific instance + function createBlob(lampBody, lampHeight) { + const blob = document.createElement("div"); + blob.className = "blob"; + + const containerWidth = lampBody.offsetWidth; + const containerHeight = lampHeight || lampBody.offsetHeight; + + const blobSizeMultiplier = parseFloat(blobSizeInput.value); + const size = + (Math.random() * 0.15 + 0.25) * containerWidth * blobSizeMultiplier; + const duration = (Math.random() * 8 + 12) / parseFloat(speedInput.value); + + const maxX = containerWidth - size; + const startX = Math.random() * maxX; + + blob.style.width = `${size}px`; + blob.style.height = `${size}px`; + blob.style.left = `${startX}px`; + blob.style.bottom = "10px"; + blob.style.position = "absolute"; + + blob.style.setProperty("--duration", `${duration}s`); + blob.style.setProperty("--start-x", "0px"); + blob.style.setProperty("--start-y", "0px"); + blob.style.setProperty( + "--mid1-x", + `${(Math.random() * 0.3 - 0.15) * containerWidth}px`, + ); + blob.style.setProperty( + "--mid1-y", + `${-(Math.random() * 0.15 + 0.25) * containerHeight}px`, + ); + blob.style.setProperty( + "--mid2-x", + `${(Math.random() * 0.4 - 0.2) * containerWidth}px`, + ); + blob.style.setProperty( + "--mid2-y", + `${-(Math.random() * 0.2 + 0.5) * containerHeight}px`, + ); + blob.style.setProperty( + "--mid3-x", + `${(Math.random() * 0.3 - 0.15) * containerWidth}px`, + ); + blob.style.setProperty( + "--mid3-y", + `${-(Math.random() * 0.15 + 0.8) * containerHeight}px`, + ); + blob.style.setProperty( + "--scale1", + (Math.random() * 0.3 + 1.0).toFixed(2), + ); + blob.style.setProperty( + "--scale2", + (Math.random() * 0.3 + 0.85).toFixed(2), + ); + blob.style.setProperty( + "--scale3", + (Math.random() * 0.3 + 0.95).toFixed(2), + ); + blob.style.animationDelay = `${Math.random() * -20}s`; + + return blob; + } + + // Update blob count for all instances + function updateBlobCount() { + const count = parseInt(blobCountInput.value); + + Object.keys(lampInstances).forEach((key) => { + const instance = lampInstances[key]; + const { lampElements, blobs } = instance; + + if (!lampElements.blobsContainer) return; + + while (blobs.length > count) { + const blob = blobs.pop(); + lampElements.blobsContainer.removeChild(blob); + } + while (blobs.length < count) { + const blob = createBlob(lampElements.lampBody); + updateBlobColors(blob); + blobs.push(blob); + lampElements.blobsContainer.appendChild(blob); + } + }); + } + + // Update blob colors + function updateBlobColors(blob) { + const color1 = blobColor1Input.value; + const color2 = blobColor2Input.value; + blob.style.background = `radial-gradient(circle at 30% 30%, ${color1}, ${color2})`; + } + + // Adjust brightness helper + function adjustBrightness(color, percent) { + const num = parseInt(color.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = (num >> 16) + amt; + const G = ((num >> 8) & 0x00ff) + amt; + const B = (num & 0x0000ff) + amt; + return ( + "#" + + ( + 0x1000000 + + (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + + (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + + (B < 255 ? (B < 1 ? 0 : B) : 255) + ) + .toString(16) + .slice(1) + ); + } + + // Update the preview for all instances + function updatePreview() { + Object.keys(lampInstances).forEach((key) => { + const instance = lampInstances[key]; + const { lampElements, blobs } = instance; + + if (!lampElements.lampBody) return; + + // Update background gradient + lampElements.lampBody.style.background = `linear-gradient(180deg, ${bgColor1Input.value} 0%, ${bgColor2Input.value} 100%)`; + + // Update cap and base color + const baseColor = caseColorInput.value; + lampElements.lampCap.style.background = `linear-gradient(180deg, ${adjustBrightness(baseColor, 40)} 0%, ${baseColor} 50%, ${adjustBrightness(baseColor, -20)} 100%)`; + lampElements.lampBase.style.background = `linear-gradient(180deg, ${baseColor} 0%, ${adjustBrightness(baseColor, -20)} 40%, ${adjustBrightness(baseColor, -40)} 100%)`; + lampElements.lampBase.style.borderTop = `1px solid ${adjustBrightness(bgColor2Input.value, -30)}`; + + // Update all blob colors + blobs.forEach((blob) => updateBlobColors(blob)); + }); + + // Update blob count + updateBlobCount(); + } + + // Update pixelation filters for all instances + function updatePixelation() { + const isPixelated = pixelateInput.checked; + const pixelSize = parseInt(pixelSizeInput.value); + + Object.keys(lampInstances).forEach((key) => { + const instance = lampInstances[key]; + const { lampElements } = instance; + + if (isPixelated) { + // Update the filter in the SVG + const filter = lampElements.svg.querySelector( + `#${lampElements.pixelateFilterId}`, + ); + const composite = filter.querySelector("feComposite[width]"); + const morphology = filter.querySelector("feMorphology"); + + composite.setAttribute("width", pixelSize); + composite.setAttribute("height", pixelSize); + morphology.setAttribute("radius", Math.floor(pixelSize / 2)); + + // Apply filter to container + lampElements.container.style.filter = `url(#${lampElements.pixelateFilterId})`; + } else { + // Remove filter + lampElements.container.style.filter = ""; + } + }); + } + + // Generate embed code + function generateEmbedCode() { + const siteUrl = window.location.origin; + let pixelateAttrs = ""; + if (pixelateInput.checked) { + pixelateAttrs = '\n data-pixelate="true"'; + if (parseInt(pixelSizeInput.value) !== 4) { + pixelateAttrs += `\n data-pixel-size="${pixelSizeInput.value}"`; + } + } + return ` + +``` + +### Customisation Options + +- `data-bg-color-1` & `data-bg-color-2`: Background gradient colors +- `data-blob-color-1` & `data-blob-color-2`: Blob gradient colors +- `data-case-color`: Color for the top cap and bottom base +- `data-blob-count`: Number of blobs (3-12) +- `data-speed`: Animation speed multiplier (0.5-1.5x) +- `data-blob-size`: Blob size multiplier (0.5-2.0x) +- `data-pixelate`: Set to "true" for a retro pixelated effect +- `data-pixel-size`: Pixel size for pixelation effect (2-10, default 4) + +--- + +## More Coming Soon + +The companion cube is coming soon! + +Got ideas for adoptables you'd like to see? Let me know! diff --git a/content/resources/button-generator/index.md b/content/resources/button-generator/index.md new file mode 100644 index 0000000..5d074e1 --- /dev/null +++ b/content/resources/button-generator/index.md @@ -0,0 +1,40 @@ +--- +title: "88x31 Button Creator" +date: 2026-01-08 +description: "Make custom 88x31 pixel buttons with text, colors, gradients, and textures" +icon: "button-generator" +demo_url: "" +source_url: "" +draft: false +--- + +Welcome to my 88x31 button creator, this is a pretty rough and ready implementation so it could be buggy, please let me know if you find any issues. + +This supports gif despite the basic `canvas` tag limitation courtesy of [gif.js](https://github.com/jnordberg/gif.js) - none of this would be possible without that project. + +Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x31) and everyone who made the buttons that appear there. You should check it out if you need inspiration for your button! + +**Important note:** Some effects and animations stack, some don't. Some work better with certain lengths of text or variables depending on text length. Experiment, see what happens. + +{{< button-generator >}} + +--- + +### Changelog + +- 07/01/2025 - Initial release. +- 08/01/2025 - Total refactor to be modular, added many more effects. +- 09/01/2025 - Rewrote the bevel inset and outset border code so they look a lot nicer at the corners. Updates throughout so that multibyte (emoji!) characters should work. + +--- + +### Cool People Who Have Linked To This Project + +[tdh.se](https://tdh.se/2026/01/88x31-pixlar-retroknapp/), +[rknight.me](https://rknight.me/blog/88x31-button-curios/), +[fyr.io](https://fyr.io/scrap/2026-01-09), +[brennan.day](https://brennan.day/resources-for-the-personal-web-a-follow-up-guide/), +[kuemmerle.name](https://kuemmerle.name/bastelglueck/), +[craney.uk](https://craney.uk/posts/stuff-this-week-74) + +Am I missing you? Email me or send a webmention! diff --git a/content/resources/lastfm-stats/index.md b/content/resources/lastfm-stats/index.md index b33e843..d4b199b 100644 --- a/content/resources/lastfm-stats/index.md +++ b/content/resources/lastfm-stats/index.md @@ -1,7 +1,6 @@ --- -title: "Last.fm Weekly Stats Script" +title: "Last.fm Weekly Stats" date: 2026-01-04 -tags: ["javascript", "api", "last.fm"] description: "Fetch and display your weekly listening stats from Last.fm" icon: "lastfm-stats" demo_url: "" @@ -9,26 +8,115 @@ source_url: "" draft: false --- -A handy script for pulling your weekly listening statistics from Last.fm's API. Perfect for tracking your music habits and creating weekly listening reports. +Get your weekly listening statistics from Last.fm's API. Enter your username below to see your top artists and track counts for different time periods. -## Features +I made this so I could easily add in listening stats to my [weekly posts](/tags/weekly-update/), if you find it useful please let me know. -- Fetches weekly top tracks from Last.fm -- Displays track count and listening time -- Formats data in a clean, readable format -- Easy to integrate into blogs or dashboards +{{< lastfm-stats-form >}} -## Setup +## Download the Shell Script -1. Get your Last.fm API key from [Last.fm API](https://www.last.fm/api) -2. Configure your username in the script -3. Run the script to fetch your weekly stats +Want to run this locally or integrate it into your own workflows? Here is a bash script I was using to generate this before I decided to make a web version. -## Output +``` +#!/bin/bash -The script returns your top tracks for the week, including: -- Track name and artist -- Play count -- Listening duration +# Last.fm Weekly Stats Script +# Fetches your Last.fm listening statistics for the past week +# +# Requirements: +# - curl (for API requests) +# - jq (for JSON parsing) +# +# Usage: ./lastfm-week.sh +# +# Setup: +# Create a .env file with: +# LASTFM_API_KEY=your_api_key_here +# LASTFM_USERNAME=your_username_here +# +# Output: Markdown-formatted stats with top artists and track counts +# +# Download from: https://ritual.sh/resources/lastfm-stats/ -Great for weekly blog posts or personal music tracking! +# Load environment variables from .env file +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +else + echo "Error: .env file not found" + exit 1 +fi + +# Check required variables +if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then + echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file" + exit 1 +fi + +API_BASE="http://ws.audioscrobbler.com/2.0/" + +# Get current timestamp +NOW=$(date +%s) +# Get timestamp from 7 days ago +WEEK_AGO=$((NOW - 604800)) + +# Fetch top artists for the week +TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5") + +# Fetch recent tracks to count this week's scrobbles +RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1") + +# Get total track count +TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total') + +# Output in markdown format +echo "## Last.fm Weekly Stats" +echo "" +echo "**Total Tracks:** ${TOTAL_TRACKS}" +echo "" +echo "**Top 5 Artists:**" +echo "" + +# Parse and display top 5 artists as markdown links +echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"' +``` + +### Shell Script Usage + +The script fetches your Last.fm stats and outputs them in markdown format. + +**Requirements:** + +- `curl` for API requests +- `jq` for JSON parsing + +**Setup:** + +1. Create a `.env` file with your credentials: + +```bash +LASTFM_API_KEY=your_api_key_here +LASTFM_USERNAME=your_username_here +``` + +2. Make the script executable: + +```bash +chmod +x lastfm-week.sh +``` + +3. Run it: + +```bash +./lastfm-week.sh +``` + +**Output:** + +The script prints markdown-formatted stats including: + +- Total tracks scrobbled this week +- Top 5 artists with play counts +- Direct links to artist pages + +Enjoy! diff --git a/content/resources/lavalamp/index.md b/content/resources/lavalamp/index.md deleted file mode 100644 index 1660430..0000000 --- a/content/resources/lavalamp/index.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "HTML/CSS Lavalamp" -date: 2026-01-04 -tags: ["css", "html", "animation"] -description: "A pure CSS lavalamp animation with floating blobs" -icon: "lavalamp" -demo_url: "" -source_url: "" -draft: false ---- - -A mesmerizing lavalamp effect created entirely with HTML and CSS. Features smooth floating animations and gradient blobs that rise and fall like a classic lava lamp. - -## Features - -- Pure CSS animation (no JavaScript required) -- Customizable colors and blob shapes -- Smooth, organic floating motion -- Responsive design - -## How It Works - -The animation uses CSS keyframes to create the illusion of blobs floating up and down. Multiple blob elements with different animation delays create a realistic lava lamp effect. - -## Usage - -Simply copy the HTML and CSS into your project. You can customize the colors by changing the gradient values and adjust the animation speed by modifying the animation duration. diff --git a/content/updates/2026-01-08-resources-section.md b/content/updates/2026-01-08-resources-section.md new file mode 100644 index 0000000..cf28c43 --- /dev/null +++ b/content/updates/2026-01-08-resources-section.md @@ -0,0 +1,10 @@ +--- +title: "2026 01 08 Resources Section" +date: 2026-01-08T09:24:45Z +tags: [] +description: "" +build: + render: never +--- + +Added the resources section with a tool to pull weekly listening stats from last.fm diff --git a/content/updates/2026-01-11-button-generator.md b/content/updates/2026-01-11-button-generator.md new file mode 100644 index 0000000..23c41fb --- /dev/null +++ b/content/updates/2026-01-11-button-generator.md @@ -0,0 +1,10 @@ +--- +title: "2026 01 11 Button Generator" +date: 2026-01-11T08:32:16Z +tags: [] +description: "" +build: + render: never +--- + +Added an 88x31 button generator to the resources section - it can do animated gifs too! \ No newline at end of file diff --git a/lastfm-week.sh b/lastfm-week.sh index c208320..1d64688 100755 --- a/lastfm-week.sh +++ b/lastfm-week.sh @@ -1,7 +1,22 @@ #!/bin/bash # Last.fm Weekly Stats Script -# Usage: ./lastfm-weekly.sh +# Fetches your Last.fm listening statistics for the past week +# +# Requirements: +# - curl (for API requests) +# - jq (for JSON parsing) +# +# Usage: ./lastfm-week.sh +# +# Setup: +# Create a .env file with: +# LASTFM_API_KEY=your_api_key_here +# LASTFM_USERNAME=your_username_here +# +# Output: Markdown-formatted stats with top artists and track counts +# +# Download from: https://ritual.sh/resources/lastfm-stats/ # Load environment variables from .env file if [ -f .env ]; then diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index d5fb00f..616ee96 100755 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -2,8 +2,8 @@ - + {{ block "title" . }}{{ .Site.Title }} {{ with .Params.Title }} | {{ . }}{{ end }}{{ end }} @@ -17,6 +17,16 @@ <link rel="stylesheet" href="{{ relURL ($.Site.BaseURL) }}{{ . }}" /> {{ end }} {{ block "favicon" . }} {{ partial "site-favicon.html" . }} {{ end }} + + <!-- Chart.js for graphs - only load if page content contains graph shortcode --> + {{ if or (findRE "{{<\\s*graph" .RawContent) (findRE "{{%\\s*graph" + .RawContent) }} + <script + src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" + integrity="sha384-9nhczxUqK87bcKHh20fSQcTGD4qq5GhayNYSYWqwBkINBhOfQLg/P5HG5lF1urn4" + crossorigin="anonymous" + ></script> + {{ end }} </head> <body> @@ -24,5 +34,23 @@ <main role="main">{{ block "main" . }}{{ end }}</main> {{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }} {{ block "scripts" . }}{{ partial "site-scripts.html" . }}{{ end }} + + <!-- Button Generator - only load if page content contains button-generator shortcode --> + {{ if or (findRE "{{<\\s*button-generator" .RawContent) (findRE "{{%\\s*button-generator" .RawContent) }} + <script src="{{ "js/gif.js" | relURL }}"></script> + <script type="module" src="{{ "js/button-generator/main.js" | relURL }}"></script> + {{ end }} + + <!-- Lava Lamp Adoptable - only load if page content contains lavalamp-adoptable shortcode --> + {{ if or (findRE "{{<\\s*lavalamp-adoptable" .RawContent) (findRE "{{%\\s*lavalamp-adoptable" .RawContent) }} + {{ $lavalampAdoptable := resources.Get "js/adoptables/lavalamp-adoptable.js" | resources.Minify | resources.Fingerprint }} + <script src="{{ $lavalampAdoptable.RelPermalink }}" integrity="{{ $lavalampAdoptable.Data.Integrity }}"></script> + {{ end }} + + <!-- Guestbook - only load on guestbook page --> + {{ if eq .Type "guestbook" }} + {{ $guestbook := resources.Get "js/guestbook.js" | resources.Minify | resources.Fingerprint }} + <script src="{{ $guestbook.RelPermalink }}" integrity="{{ $guestbook.Data.Integrity }}"></script> + {{ end }} </body> </html> diff --git a/layouts/_default/list.webmentions.json b/layouts/_default/list.webmentions.json new file mode 100644 index 0000000..2a04202 --- /dev/null +++ b/layouts/_default/list.webmentions.json @@ -0,0 +1,36 @@ +{{- $links := slice -}} +{{- $excludeDomains := slice "ritual.sh" "last.fm" "www.last.fm" "amazon.co.uk" "www.amazon.co.uk" "amazon.com" "www.amazon.com" -}} + +{{- range .Site.RegularPages -}} + {{- $postUrl := .Permalink -}} + {{- $content := .Content -}} + + {{- /* Extract all hrefs from content */ -}} + {{- $hrefs := findRE `href="([^"]+)"` $content -}} + + {{- range $hrefs -}} + {{- $href := . | replaceRE `href="([^"]+)"` "$1" -}} + + {{- /* Only external links */ -}} + {{- if hasPrefix $href "http" -}} + {{- $shouldExclude := false -}} + + {{- /* Check if href contains any excluded domain */ -}} + {{- range $excludeDomains -}} + {{- if in $href . -}} + {{- $shouldExclude = true -}} + {{- end -}} + {{- end -}} + + {{- if not $shouldExclude -}} + {{- $links = $links | append (dict "source" $postUrl "target" $href) -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +[ +{{ range $i, $link := $links -}} +{{ if $i }}, +{{ end }} {"source":"{{ $link.source }}","target":"{{ $link.target }}"} +{{ end -}} +] \ No newline at end of file diff --git a/layouts/about/single.html b/layouts/about/single.html index 8f8061d..8f52f8c 100644 --- a/layouts/about/single.html +++ b/layouts/about/single.html @@ -1,17 +1,15 @@ -{{ define "header" }}{{ partial "page-header.html" . }}{{ end }} {{ define -"main" }} +{{ define "main" }} <article class="about-page"> <div class="about-content"> <div class="content-screen"> <div class="about-header">{{ partial "elements/lavalamp.html" . }}</div> <div class="screen-display crt no-scroll"> - > about -v <br /> + > about -s <br /> <span>Name</span> / <span class="info">Dan (He/Him)</span><br /> <span>Age</span> / <span class="info">40-something</span><br /> <span>Location</span> / <span class="info">UK 🇬🇧</span><br /><br /> <span>Interests</span> / - <span class="info"> - Programming. Music. Movies. Tech. Photography. </span + <span class="info"> Programming. Music. Movies. Tech. Photography.</span ><br /> <span class="cursor-blink">_</span> </div> @@ -23,6 +21,53 @@ partial "elements/lcd-screen.html" (dict "text" .) }} {{ end }} </div> + <div class="wide-item"> + <div class="content-screen"> + <div class="screen-display crt"> + > about -v + <br /><br /> + <p> + <span>Intro</span> / + <span class="info"> + How do you do? Welcome to my little corner of the internet. I've + been building websites since the original IndieWeb, so am happy to + see the movement taking off and people embracing building and + curating their own websites. This is my virtual garden, you can + read more about why of it all in my "manifesto" below.</span + ><br /> + </p> + + <p> + <span>What's here</span> / + <span class="info"> + I'm working on creating pages on my varied interests, so far you + can find a section on the things I have been + <a href="/audio"> listening to and enjoying</a>, and what I have + been listening to them on. There's also a + <a href="/blog">blog(gish)</a> where I am going to write whatever + comes to mind, and theoretically a brief weekly update*.<br /><br /> + I am working on a resources section to share some of my CSS + artwork and various random scripts with the world. Please look + foward to it. + <br /><br /> + <em + >* I have never kept up with a blog before, don't expect + much.</em + ></span + ><br /> + </p> + <p> + <span>Click around</span> / + <span class="info"> + It's probably best if you just have a little click around for + yourself. Good luck.</span + ><br /> + </p> + <span class="cursor-blink">_</span> + </div> + </div> + </div> + <div class="wide-item manifesto-container"> <div class="content-screen"> <div class="screen-display crt"> @@ -105,10 +150,96 @@ ><br /> Camera / <span class="info">Fuji X-T5</span><br /> Audio / <span class="info">Hiby R4 EVA, Fiio FT-1</span> <br /><br /> - More info coming soon... - <br /><span class="cursor-blink">_</span> + Check out my <a href="/now">/now</a> page to see more hardware and + software information. <br /><span class="cursor-blink">_</span> + </div> + </div> + + <div class="about-music hidden-xxl-down"> + <div class="music"> + {{ partial "elements/ipod.html" . }} + + <div class="">{{ partial "elements/vu-meter.html" . }}</div> + </div> + </div> + + <div class="content-screen wide-item"> + <div class="screen-display crt"> + > cat contact_info<br /> + <div class="about-contact-section"> + <p> + If you have any comments, questions, corrections, or just fancy + saying "hello" please feel free to get in touch. + </p> + <p> + I am still debating with myself if I want to put more effort into a + mastodon/bluesky type thing, so for now email is the best way to + talk to me. PGP is available if that's your kind of thing. + </p> + <div class="contact-info"> + <div class="contact-item"> + <span class="contact-label">Email</span> / + <a href="mailto:dan@ritual.sh" class="info">dan@ritual.sh</a> + </div> + <div class="contact-item"> + <span class="contact-label">PGP Public Key</span> / + <div class="pgp-actions"> + <a href="/publickey.asc" download class="pgp-button"> + <span class="button-icon">↓</span> Download + </a> + <button id="copy-pgp-key-about" class="pgp-button"> + <span class="button-icon">⎘</span> Copy to Clipboard + </button> + </div> + </div> + <div id="copy-feedback-about" class="copy-feedback"></div> + </div> + </div> + <pre> +| .-. +| / \ .-. +| / \ / \ .-. .-. _ _ ++--/-------\-----/-----\-----/---\---/---\---/-\-/-\/\/--- +| / \ / \ / '-' '-' +|/ '-' '-' + + </pre> + <span class="cursor-blink">_</span> </div> </div> </div> </article> + +<script> + document.addEventListener("DOMContentLoaded", function () { + const copyButton = document.getElementById("copy-pgp-key-about"); + const feedback = document.getElementById("copy-feedback-about"); + + if (copyButton) { + copyButton.addEventListener("click", async function () { + try { + const response = await fetch("/publickey.asc"); + const pgpKey = await response.text(); + + await navigator.clipboard.writeText(pgpKey); + + feedback.textContent = "PGP key copied to clipboard!"; + feedback.classList.add("show", "success"); + + setTimeout(() => { + feedback.classList.remove("show"); + }, 3000); + } catch (err) { + feedback.textContent = "Failed to copy key"; + feedback.classList.add("show", "error"); + + setTimeout(() => { + feedback.classList.remove("show"); + }, 3000); + } + }); + } + }); +</script> + {{ end }} diff --git a/layouts/blog/single.html b/layouts/blog/single.html index 4a399ba..b8ea142 100644 --- a/layouts/blog/single.html +++ b/layouts/blog/single.html @@ -23,6 +23,8 @@ <div class="blog-summary">{{ .Content }}</div> </article> + {{ partial "contact-section.html" . }} + <nav class="blog-post-navigation"> <div class="post-nav-links"> {{ with .PrevInSection }} @@ -46,4 +48,7 @@ <div class="blogs-lavalamp">{{ partial "elements/lavalamp.html" . }}</div> </div> </div> + +<script src="/js/footnote-scroll.js"></script> + {{ end }} diff --git a/layouts/guestbook/single.html b/layouts/guestbook/single.html new file mode 100644 index 0000000..68bd581 --- /dev/null +++ b/layouts/guestbook/single.html @@ -0,0 +1,112 @@ +{{ define "main" }} +<article class="guestbook-page"> + <div class="guestbook-content"> + <!-- Header Section --> + <div> + <div class="guestbook-header content-screen"> + <div class="screen-display crt"> + <div class="terminal-output"> + > guestbook --info<br /> + <span class="info">{{ .Content }}</span><br /> + <br /> + <span class="cursor-blink">_</span> + </div> + </div> + </div> + <div class="guestbook-floppy"> + {{ partial "elements/floppy.html" (dict "title" "Guestbook Entries" + "lines" (slice "" "" "DO NOT DELETE" "" "") "bgColor" "#1a4d8f" + "bgColorDark" "#0d2747") }} + </div> + </div> + + <!-- Guestbook Form --> + <div class="guestbook-form-container content-screen"> + <div class="screen-display crt"> + <div class="terminal-output">> new-entry --interactive<br /></div> + <form id="guestbook-form" class="guestbook-form"> + <div class="form-row"> + <label for="gb-name">> Name:</label> + <input + type="text" + id="gb-name" + name="name" + required + maxlength="50" + placeholder="Your name" + /> + </div> + + <div class="form-row"> + <label for="gb-email">> Email:</label> + <input + type="email" + id="gb-email" + name="email" + placeholder="your@email.com (optional)" + /> + </div> + + <div class="form-row"> + <label for="gb-website">> Website:</label> + <input + type="url" + id="gb-website" + name="website" + placeholder="https://yoursite.com (optional)" + /> + </div> + + <div class="form-row"> + <label for="gb-message">> Message:</label> + <textarea + id="gb-message" + name="message" + required + maxlength="500" + rows="3" + placeholder="Your message..." + ></textarea> + </div> + + <div id="form-feedback" class="form-feedback"></div> + + <button type="submit" id="submit-btn" class="terminal-button"> + <span class="button-text">[ SUBMIT ]</span> + </button> + </form> + </div> + </div> + + <!-- Guestbook Entries --> + <div class="guestbook-entries-container content-screen wide-item"> + <div class="screen-display crt"> + <div class="terminal-output"> + > list-entries --all<br /> + <br /> + </div> + + <div id="entries-loading" class="loading-state"> + <span class="loading-text">Loading entries</span + ><span class="loading-dots">...</span> + <span class="cursor-blink">_</span> + </div> + + <div id="entries-error" class="error-state" style="display: none"> + <span class="error-text">ERROR: Failed to load entries</span><br /> + <span class="dim">Please try again later.</span><br /> + <span class="cursor-blink">_</span> + </div> + + <div id="entries-list" class="entries-list" style="display: none"> + <!-- Entries will be dynamically inserted here --> + </div> + + <div id="pagination" class="pagination" style="display: none"> + <!-- Pagination controls will be inserted here --> + </div> + </div> + </div> + </div> +</article> +{{ end }} diff --git a/layouts/humans.txt b/layouts/humans.txt index 71ec599..68c8639 100644 --- a/layouts/humans.txt +++ b/layouts/humans.txt @@ -1,6 +1,6 @@ /* TEAM */ -Creator: Dan -Contact: dan [at] unbo.lt +Creator: Dan Baker +Contact: dan [at] ritual.sh From: ritual.sh /* THANKS */ @@ -16,7 +16,6 @@ Software: Built with care /* PHILOSOPHY */ No tracking -No analytics No AI training data No engagement metrics No corporate platforms diff --git a/layouts/index.html b/layouts/index.html index 109f8ef..d442f57 100644 --- a/layouts/index.html +++ b/layouts/index.html @@ -6,7 +6,7 @@ <div class="window hidden-lg-down">{{ partial "elements/window.html" . }}</div> <!-- Neon sign above monitor --> -{{ partial "elements/neon-sign.html" . }} +<div class="homepage-neon">{{ partial "elements/neon-sign.html" . }}</div> <!-- Sticky notes --> <div class="sticky-note note1 hidden-xl-down">fix bugs</div> @@ -89,18 +89,8 @@ </div> <div class="secondary-screen wall-monitor-4 hidden-xl-down"> - <div class="screen-display tiny amber crt"> + <div class="screen-display amber crt" id="crt-logs"> > tail -f /var/log<br /> - [INFO] Process OK<br /> - [WARN] High load detected - time for coffee break<br /> - [INFO] Connected to database (it's in a relationship now)<br /> - [ERROR] 404: Motivation not found<br /> - [WARN] Firewall detected actual fire, calling emergency services<br /> - [INFO] Successfully hacked into mainframe (jk it's just localhost)<br /> - [ERROR] Keyboard not found. Press F1 to continue.<br /> - [WARN] Too many open tabs. Browser having existential crisis.<br /> - [INFO] Ping 127.0.0.1 - there's no place like home<br /> - [ERROR] SQL injection attempt detected. Nice try, Bobby Tables.<br /> <span class="cursor-blink">_</span> </div> </div> @@ -239,7 +229,7 @@ <div> <a href="/audio/"> <div class="music"> - <div class="music-text">MUSIC & AUDIO GEAR</div> + <div class="music-text">Music & Audio Gear</div> {{ partial "elements/ipod.html" . }} @@ -265,6 +255,26 @@ </a> </div> + <div> + <a href="/resources/"> + <div class="nav-cube"> + {{ partial "elements/companion-cube.html" . }} + <div class="nav-cube-text">Experiments & Resources</div> + </div> + </a> + </div> + + <div> + <div class="nav-floppy"> + <a href="/guestbook/"> + {{ partial "elements/floppy.html" (dict "title" "Guestbook" "lines" + (slice "" "" "DO NOT DELETE" "" "") "bgColor" "#1a4d8f" "bgColorDark" + "#0d2747") }} + <div class="nav-floppy-text">Guestbook</div> + </a> + </div> + </div> + <div>{{ partial "elements/crt-tv.html" . }}</div> </div> diff --git a/layouts/media/list.html b/layouts/media/list.html index 4f03ef8..af98f68 100644 --- a/layouts/media/list.html +++ b/layouts/media/list.html @@ -14,49 +14,63 @@ ▀██▀ ▀██▄▄▀█▄▄▄▄█▀███▄██▄▀█▄██ ████████▄▀███▀ ▀████ ██ ▀▀▀ -</pre> +</pre + > </div> <div class="media-layout"> <div class="media-list"> + <h3>Recent Media</h3> {{ range .Paginator.Pages }} - <div class="media-item" data-type="{{ index .Params.tags 0 }}"> - <div class="media-cover"> - {{ with .Resources.GetMatch "cover.*" }} - {{ $image := .Resize "320x webp q85" }} - <img src="{{ $image.RelPermalink }}" alt="{{ $.Title }}" loading="lazy" width="{{ $image.Width }}" height="{{ $image.Height }}"> - {{ else }} - <div class="no-cover"> - <span class="cover-placeholder">NO COVER</span> - </div> - {{ end }} - </div> - <div class="media-info"> - <div class="media-meta"> - <div class="media-type">{{ index .Params.tags 0 | upper }}</div> - {{ if .Params.format }} - <div class="media-format">{{ .Params.format | upper }}</div> - {{ end }} - <div class="media-date">{{ .Date.Format "02-01-2006" }}</div> - </div> - <h3 class="media-title">{{ .Title }}</h3> - {{ if .Params.rating }} - <div class="media-rating"> - <span class="rating-value">{{ .Params.rating }}</span> - <span class="rating-max">/10</span> - </div> - {{ end }} + <div class="media-item" data-type="{{ index .Params.tags 0 }}"> + <div class="media-cover"> + {{ with .Resources.GetMatch "cover.*" }} {{ $image := .Resize + "320x webp q85" }} + <img + src="{{ $image.RelPermalink }}" + alt="{{ $.Title }}" + loading="lazy" + width="{{ $image.Width }}" + height="{{ $image.Height }}" + /> + {{ else }} + <div class="no-cover"> + <span class="cover-placeholder">NO COVER</span> </div> + {{ end }} </div> - {{ end }} - - {{ partial "pagination.html" .Paginator }} + <div class="media-info"> + <div class="media-meta"> + <div class="media-type"> + {{ index .Params.tags 0 | upper }} + </div> + {{ if .Params.format }} + <div class="media-format">{{ .Params.format | upper }}</div> + {{ end }} + <div class="media-date">{{ .Date.Format "02-01-2006" }}</div> + </div> + <h3 class="media-title">{{ .Title }}</h3> + {{ if .Params.rating }} + <div class="media-rating"> + <span class="rating-value">{{ .Params.rating }}</span> + <span class="rating-max">/10</span> + </div> + {{ end }} + </div> + </div> + {{ end }} {{ partial "pagination.html" .Paginator }} </div> <div class="lastfm-sidebar"> <div class="lastfm-header"> <h3>Recently Listened</h3> - <a href="https://www.last.fm/user/ritualplays" target="_blank" rel="noopener noreferrer" class="lastfm-profile-link">last.fm →</a> + <a + href="https://www.last.fm/user/ritualplays" + target="_blank" + rel="noopener noreferrer" + class="lastfm-profile-link" + >last.fm →</a + > </div> <div class="lastfm-tracks" id="lastfm-tracks"> <div class="lastfm-loading">Loading tracks...</div> @@ -67,6 +81,4 @@ </div> </div> </div> - -<script src="/js/pages/media.js"></script> {{ end }} diff --git a/layouts/partials/contact-section.html b/layouts/partials/contact-section.html new file mode 100644 index 0000000..76797c6 --- /dev/null +++ b/layouts/partials/contact-section.html @@ -0,0 +1,27 @@ +<section class="contact-section"> + <h2 class="contact-title">Contact</h2> + <div class="contact-content"> + <p> + If you found this interesting, have any comments, questions, corrections, + or just fancy saying "hello" please feel free to get in touch. If you + really enjoyed it you could consider + <a href="https://ko-fi.com/ritual" target="_blank">buying me a coffee</a>. + </p> + <div class="contact-email"> + <span class="contact-label">Email:</span> + <a href="mailto:dan@ritual.sh">dan@ritual.sh</a> + </div> + <div class="contact-pgp"> + <span class="contact-label">PGP Public Key:</span> + <div class="pgp-actions"> + <a href="/publickey.asc" download class="pgp-button download-key"> + <span class="button-icon">↓</span> Download + </a> + <button class="pgp-button copy-key pgp-copy-trigger"> + <span class="button-icon">⎘</span> Copy to Clipboard + </button> + </div> + <div class="copy-feedback pgp-copy-feedback"></div> + </div> + </div> +</section> diff --git a/layouts/partials/elements/companion-cube.html b/layouts/partials/elements/companion-cube.html new file mode 100644 index 0000000..0e6327a --- /dev/null +++ b/layouts/partials/elements/companion-cube.html @@ -0,0 +1,41 @@ +<div class="companion-cube-container"> + <div class="portal-icon"> + <div class="portal blue-portal"></div> + <div class="portal orange-portal"></div> + </div> + <div class="cube-one"> + <div class="companion-cube-face"> + <div class="grey-bar horizontal-top"></div> + <div class="grey-bar horizontal-bottom"></div> + <div class="grey-bar vertical-left"></div> + <div class="grey-bar vertical-right"></div> + + <div class="cube-center-circle"> + <div class="pink-bar vertical"></div> + <div class="pink-bar horizontal"></div> + + <div class="center-inner-ring"></div> + + <div class="heart-icon">♥︎</div> + </div> + </div> + </div> + + <div class="cube-two"> + <div class="companion-cube-face"> + <div class="grey-bar horizontal-top"></div> + <div class="grey-bar horizontal-bottom"></div> + <div class="grey-bar vertical-left"></div> + <div class="grey-bar vertical-right"></div> + + <div class="cube-center-circle"> + <div class="pink-bar vertical"></div> + <div class="pink-bar horizontal"></div> + + <div class="center-inner-ring"></div> + + <div class="heart-icon">♥︎</div> + </div> + </div> + </div> +</div> diff --git a/layouts/partials/site-header.html b/layouts/partials/site-header.html index e8b6cc4..72ff1f1 100755 --- a/layouts/partials/site-header.html +++ b/layouts/partials/site-header.html @@ -1,15 +1,3 @@ <header> - <div> - {{ partial "site-navigation.html" . }} - <div> - <!-- <h1> - {{ .Title | default .Site.Title }} - </h1> --> - {{ with .Params.description }} - <h2> - {{ . }} - </h2> - {{ end }} - </div> - </div> -</header> \ No newline at end of file + <div>{{ partial "site-navigation.html" . }}</div> +</header> diff --git a/layouts/partials/site-scripts.html b/layouts/partials/site-scripts.html index 5f3690d..f61de47 100644 --- a/layouts/partials/site-scripts.html +++ b/layouts/partials/site-scripts.html @@ -7,11 +7,18 @@ {{ $filtered := slice }} {{ range $remaining }} {{ $path := .RelPermalink }} - {{ if and (not (strings.Contains $path "/terminal.js")) (not (strings.Contains $path "/init.js")) }} + {{ if and (not (strings.Contains $path "/terminal.js")) (not (strings.Contains $path "/init.js")) (not (strings.Contains $path "/button-generator.js")) (not (strings.Contains $path "/adoptables/")) (not (strings.Contains $path "/guestbook.js")) }} {{ $filtered = $filtered | append . }} {{ end }} {{ end }} -{{ $allFiles := slice $terminalShell | append $filtered | append $init | append $commandFiles | append $subfolderFiles }} +{{ $filteredSubfolders := slice }} +{{ range $subfolderFiles }} + {{ $path := .RelPermalink }} + {{ if not (strings.Contains $path "/adoptables/") }} + {{ $filteredSubfolders = $filteredSubfolders | append . }} + {{ end }} +{{ end }} +{{ $allFiles := slice $terminalShell | append $filtered | append $init | append $commandFiles | append $filteredSubfolders }} {{ $terminalBundle := $allFiles | resources.Concat "js/terminal-bundle.js" | resources.Minify | resources.Fingerprint }} <!-- prettier-ignore-end --> <script diff --git a/layouts/resources/list.html b/layouts/resources/list.html index 7b25983..b207e6f 100644 --- a/layouts/resources/list.html +++ b/layouts/resources/list.html @@ -1,20 +1,39 @@ {{ define "main" }} -<div class="resources-page"> +<div class="resources-page portal-theme"> + <div class="portal-bg-elements"> + <div class="aperture-logo"></div> + <div class="test-chamber-grid"></div> + </div> + <div class="resources-container"> - <div class="whiteboard"> - <div class="whiteboard-header"> - <h1>{{ .Title }}</h1> - <div class="whiteboard-description"> - {{ .Content }} + <div class="portal-header"> + <div class="portal-icon blue-portal hidden-lg-down"></div> + <h1 class="portal-title">Resources</h1> + <div class="portal-icon orange-portal hidden-md-down"></div> + </div> + + <div class="portal-sign"> + <div class="portal-sign-decor"> + <div class="portal-sign-text">{{ .Content }}</div> + <div class="portal-sign-number">06</div> + <div class="portal-sign-sub">06/19</div> + </div> + <div class="portal-sign-lines"></div> + + <div class="portal-sign-content"> + <div class="portal-sign-header"> + <div class="portal-description"></div> + </div> + + <div class="portal-sign-items"> + {{ range .Pages }} {{ .Render "summary" }} {{ end }} </div> </div> - - <div class="whiteboard-pins"> - {{ range .Pages }} - {{ .Render "summary" }} - {{ end }} - </div> </div> - </div> + + <div class="background-cube"> + {{ partial "elements/companion-cube.html" . }} + </div> + </div> </div> {{ end }} diff --git a/layouts/resources/single.html b/layouts/resources/single.html index 1229678..3554198 100644 --- a/layouts/resources/single.html +++ b/layouts/resources/single.html @@ -1,8 +1,16 @@ {{ define "main" }} <div class="resource-single"> + <div class="test-chamber-grid"> + </div> + <article class="resource-content"> + <!-- Aperture Science hazard stripes in corners --> + + + <!-- Animated energy particles --> + <div class="energy-particles"></div> + <header class="resource-header"> - <div class="resource-icon-large {{ .Params.icon }}"></div> <h1>{{ .Title }}</h1> {{ if .Params.description }} <p class="lead">{{ .Params.description }}</p> @@ -28,6 +36,8 @@ {{ .Content }} </div> + {{ partial "contact-section.html" . }} + <nav class="resource-navigation"> {{ with .PrevInSection }} <a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a> @@ -38,5 +48,8 @@ {{ end }} </nav> </article> + <div class="background-cube"> + {{ partial "elements/companion-cube.html" . }} + </div> </div> {{ end }} diff --git a/layouts/resources/summary.html b/layouts/resources/summary.html index 4962fdf..2896b2b 100644 --- a/layouts/resources/summary.html +++ b/layouts/resources/summary.html @@ -1,25 +1,8 @@ <div class="resource-pin" data-icon="{{ .Params.icon }}"> - <div class="pin-tack"></div> - <div class="resource-card"> - <div class="resource-icon {{ .Params.icon }}"></div> - <div class="resource-info"> - <h2><a href="{{ .Permalink }}">{{ .Title }}</a></h2> - <p class="resource-description">{{ .Params.description }}</p> - {{ if .Params.tags }} - <div class="resource-tags"> - {{ range .Params.tags }} - <span class="tag">{{ . }}</span> - {{ end }} - </div> - {{ end }} - <div class="resource-links"> - {{ if .Params.demo_url }} - <a href="{{ .Params.demo_url }}" class="resource-link demo" target="_blank">Demo</a> - {{ end }} - {{ if .Params.source_url }} - <a href="{{ .Params.source_url }}" class="resource-link source" target="_blank">Source</a> - {{ end }} - </div> + <a href="{{ .Permalink }}"> + <div class="resource-card"> + <div class="resource-icon {{ .Params.icon }}"></div> + <div class="resource-info">{{ .Title }}</div> </div> - </div> + </a> </div> diff --git a/layouts/shortcodes/button-generator.html b/layouts/shortcodes/button-generator.html new file mode 100644 index 0000000..2f264b9 --- /dev/null +++ b/layouts/shortcodes/button-generator.html @@ -0,0 +1,22 @@ +<div id="button-generator-app"> + <div class="generator-container"> + <div class="preview-section"> + <h3 class="hidden-md-down">Preview</h3> + <div class="preview-container"> + <div class="preview-wrapper"> + <canvas id="button-canvas" width="88" height="31"></canvas> + </div> + </div> + <button id="download-button" class="btn-primary">Download Button</button> + + <div class="presets-container hidden-md-down"> + <h3>Presets</h3> + <button id="preset-random" class="btn-secondary">Random Button</button> + <button id="preset-classic" class="btn-secondary">Classic Style</button> + <button id="preset-modern" class="btn-secondary">Modern Style</button> + </div> + </div> + + <div class="controls-section"></div> + </div> +</div> diff --git a/layouts/shortcodes/graph.html b/layouts/shortcodes/graph.html new file mode 100644 index 0000000..cb4c445 --- /dev/null +++ b/layouts/shortcodes/graph.html @@ -0,0 +1,350 @@ +{{- $id := .Get "id" | default (printf "chart-%d" now.UnixNano) -}} +{{- $type := .Get "type" | default "line" -}} +{{- $title := .Get "title" | default "" -}} +{{- $height := .Get "height" | default "400" -}} +{{- $csvFile := .Get "csv" -}} +{{- $labelCol := .Get "labelColumn" | default "Month" -}} +{{- $dataCol := .Get "dataColumn" | default "Questions" -}} +{{- $dataColumns := .Get "dataColumns" | default "" -}} +{{- $datasetLabels := .Get "datasetLabels" | default "" -}} +{{- $yAxisIDs := .Get "yAxisIDs" | default "" -}} +{{- $dateFormat := .Get "dateFormat" | default "2006-01" -}} +{{- $skipRows := .Get "skipRows" | default 0 | int -}} +{{- $maxRows := .Get "maxRows" | default 0 | int -}} + +{{- $chartData := dict -}} + +{{- if $csvFile -}} + {{/* CSV file mode - supports multiple CSV files */}} + {{- $csvFiles := strings.Split $csvFile "," -}} + {{- $dataColsList := slice -}} + {{- if ne $dataColumns "" -}} + {{- $dataColsList = strings.Split $dataColumns "," -}} + {{- end -}} + {{- $labelsList := slice -}} + {{- if ne $datasetLabels "" -}} + {{- $labelsList = strings.Split $datasetLabels "," -}} + {{- end -}} + {{- $yAxisList := slice -}} + {{- if ne $yAxisIDs "" -}} + {{- $yAxisList = strings.Split $yAxisIDs "," -}} + {{- end -}} + + {{- $labels := slice -}} + {{- $datasets := slice -}} + + {{/* Process each CSV file */}} + {{- range $fileIdx, $csvFileName := $csvFiles -}} + {{- $csvFileName = strings.TrimSpace $csvFileName -}} + {{- $csvResource := $.Page.Resources.GetMatch $csvFileName -}} + {{- if not $csvResource -}} + {{- errorf "CSV file '%s' not found in page bundle for %s. Make sure the file exists in the same directory as index.md" $csvFileName $.Page.File.Path -}} + {{- end -}} + + {{- $csvData := $csvResource | transform.Unmarshal -}} + {{- $data := slice -}} + + {{/* Determine which data column to use */}} + {{- $currentDataCol := $dataCol -}} + {{- if ge $fileIdx (len $dataColsList) -}} + {{- $currentDataCol = $dataCol -}} + {{- else -}} + {{- $currentDataCol = strings.TrimSpace (index $dataColsList $fileIdx) -}} + {{- end -}} + + {{/* Determine dataset label */}} + {{- $datasetLabel := $currentDataCol -}} + {{- if lt $fileIdx (len $labelsList) -}} + {{- $datasetLabel = strings.TrimSpace (index $labelsList $fileIdx) -}} + {{- end -}} + + {{/* Determine Y-axis ID */}} + {{- $yAxisID := "y" -}} + {{- if lt $fileIdx (len $yAxisList) -}} + {{- $yAxisID = strings.TrimSpace (index $yAxisList $fileIdx) -}} + {{- end -}} + + {{/* Process CSV rows */}} + {{- range $idx, $row := $csvData -}} + {{- if gt $idx $skipRows -}} + {{- if or (eq $maxRows 0) (le (len $data) $maxRows) -}} + {{/* Only set labels once from the first CSV file */}} + {{- if eq $fileIdx 0 -}} + {{/* Get label value */}} + {{- $labelRaw := "" -}} + {{- if reflect.IsMap $row -}} + {{- $labelRaw = index $row $labelCol -}} + {{- else -}} + {{/* Handle as slice/array - first column is label */}} + {{- $labelRaw = index $row 0 -}} + {{- end -}} + + {{- $label := $labelRaw -}} + + {{/* Try to parse and format date if it looks like a timestamp */}} + {{- $labelStr := printf "%v" $labelRaw -}} + {{- if strings.Contains $labelStr " 00:00:00" -}} + {{- $parsedTime := time.AsTime $labelStr -}} + {{- $label = $parsedTime.Format $dateFormat -}} + {{- end -}} + + {{- $labels = $labels | append $label -}} + {{- end -}} + + {{/* Get data value */}} + {{- $dataValue := 0 -}} + {{- if reflect.IsMap $row -}} + {{- $dataValue = index $row $currentDataCol | int -}} + {{- else -}} + {{/* Handle as slice/array - second column is data */}} + {{- $dataValue = index $row 1 | int -}} + {{- end -}} + + {{- $data = $data | append $dataValue -}} + {{- end -}} + {{- end -}} + {{- end -}} + + {{/* Add this dataset to the datasets array */}} + {{- $datasets = $datasets | append (dict "label" $datasetLabel "data" $data "yAxisID" $yAxisID) -}} + {{- end -}} + + {{- $chartData = dict "labels" $labels "datasets" $datasets -}} +{{- else -}} + {{/* JSON inline mode */}} + {{- if .Inner -}} + {{- $chartData = .Inner | transform.Unmarshal -}} + {{- else -}} + {{- errorf "Graph shortcode requires either CSV file or inline JSON data" -}} + {{- end -}} +{{- end -}} + +<div class="graph-container" style="height: {{ $height }}px;"> + {{- if $title -}} + <h3 class="graph-title">{{ $title }}</h3> + {{- end -}} + <canvas id="{{ $id }}"></canvas> +</div> + +<script> +(function() { + // Wait for Chart.js to load + function initChart() { + if (typeof Chart === 'undefined') { + setTimeout(initChart, 100); + return; + } + + const ctx = document.getElementById('{{ $id }}').getContext('2d'); + + // Site color scheme - green, blue, amber + const colors = { + primary: 'rgb(0, 255, 0)', // green + secondary: 'rgb(0, 191, 255)', // blue/cyan + tertiary: 'rgb(255, 153, 0)', // amber/orange + quaternary: 'rgb(173, 255, 47)', // greenyellow + background: 'rgba(0, 255, 0, 0.1)', + grid: 'rgba(0, 255, 0, 0.2)', + text: 'rgb(0, 255, 0)' + }; + + const chartData = {{ $chartData | jsonify | safeJS }}; + + console.log('Chart ID: {{ $id }}'); + console.log('Chart data:', chartData); + console.log('Labels count:', chartData.labels ? chartData.labels.length : 0); + console.log('Data count:', chartData.datasets && chartData.datasets[0] ? chartData.datasets[0].data.length : 0); + + // Validate data + if (!chartData || !chartData.datasets || chartData.datasets.length === 0) { + console.error('Invalid chart data - no datasets found'); + return; + } + + // Check if any dataset uses the y1 axis + const usesY1Axis = chartData.datasets.some(dataset => dataset.yAxisID === 'y1'); + + // Apply terminal styling to datasets + if (chartData.datasets) { + chartData.datasets.forEach((dataset, idx) => { + const colorOptions = [colors.primary, colors.secondary, colors.tertiary, colors.quaternary]; + const baseColor = colorOptions[idx % colorOptions.length]; + + console.log('Dataset', idx, 'using color:', baseColor); + + if (!dataset.borderColor) { + dataset.borderColor = baseColor; + } + if (!dataset.backgroundColor && '{{ $type }}' === 'line') { + dataset.backgroundColor = baseColor.replace('rgb', 'rgba').replace(')', ', 0.1)'); + } else if (!dataset.backgroundColor) { + dataset.backgroundColor = baseColor.replace('rgb', 'rgba').replace(')', ', 0.6)'); + } + if (dataset.borderWidth === undefined) { + dataset.borderWidth = 2; + } + if (dataset.tension === undefined && '{{ $type }}' === 'line') { + dataset.tension = 0.1; + } + if (dataset.pointBackgroundColor === undefined && '{{ $type }}' === 'line') { + dataset.pointBackgroundColor = baseColor; + } + if (dataset.pointBorderColor === undefined && '{{ $type }}' === 'line') { + dataset.pointBorderColor = '#000'; + } + if (dataset.pointRadius === undefined && '{{ $type }}' === 'line') { + dataset.pointRadius = 2; + } + if (dataset.pointHoverRadius === undefined && '{{ $type }}' === 'line') { + dataset.pointHoverRadius = 4; + } + }); + } + + // Build scales configuration + const scales = { + x: { + offset: true, + grid: { + color: colors.grid, + drawBorder: true, + borderColor: colors.secondary, + borderWidth: 2 + }, + ticks: { + color: colors.text, + font: { + family: 'monospace', + size: 10 + }, + maxRotation: 45, + minRotation: 45, + autoSkip: true, + maxTicksLimit: 30, + padding: 8 + }, + afterFit: function(scale) { + scale.paddingBottom = 20; // Extra space for rotated labels + } + }, + y: { + type: 'linear', + position: 'left', + grid: { + color: colors.grid, + drawBorder: true, + borderColor: colors.secondary, + borderWidth: 2 + }, + ticks: { + color: colors.text, + font: { + family: 'monospace', + size: 11 + }, + padding: 5 + } + } + }; + + // Only add y1 axis if it's actually used by any dataset + if (usesY1Axis) { + scales.y1 = { + type: 'linear', + position: 'right', + grid: { + drawOnChartArea: false, // Don't draw grid lines for secondary axis + drawBorder: true, + borderColor: colors.tertiary, + borderWidth: 2 + }, + ticks: { + color: colors.text, + font: { + family: 'monospace', + size: 11 + }, + padding: 5 + } + }; + } + + const config = { + type: '{{ $type }}', + data: chartData, + options: { + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { + bottom: 30, + left: 10, + right: 10 + } + }, + plugins: { + legend: { + display: chartData.datasets && chartData.datasets.length > 1, + labels: { + color: colors.text, + font: { + family: 'monospace', + size: 12 + }, + boxWidth: 15, + padding: 10, + generateLabels: function(chart) { + const labels = Chart.defaults.plugins.legend.labels.generateLabels(chart); + labels.forEach(label => { + label.fillStyle = label.strokeStyle; + }); + return labels; + } + } + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: colors.primary, + bodyColor: colors.text, + borderColor: colors.secondary, + borderWidth: 1, + titleFont: { + family: 'monospace', + size: 14, + weight: 'bold' + }, + bodyFont: { + family: 'monospace', + size: 12 + }, + padding: 12, + displayColors: true, + callbacks: { + labelColor: function(context) { + return { + borderColor: context.dataset.borderColor, + backgroundColor: context.dataset.borderColor + }; + } + } + } + }, + scales: scales, + animation: { + duration: 1000, + easing: 'easeOutQuart' + } + } + }; + + new Chart(ctx, config); + console.log('Chart initialized successfully'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initChart); + } else { + initChart(); + } +})(); +</script> diff --git a/layouts/shortcodes/lastfm-stats-form.html b/layouts/shortcodes/lastfm-stats-form.html new file mode 100644 index 0000000..65dd0f8 --- /dev/null +++ b/layouts/shortcodes/lastfm-stats-form.html @@ -0,0 +1,53 @@ +<div id="lastfm-stats-app"> + <div class="stats-form"> + <div class="form-group"> + <label for="lastfm-username">Last.fm Username</label> + <input + type="text" + id="lastfm-username" + placeholder="Enter your username" + /> + </div> + + <div class="form-group"> + <label for="time-period">Time Period</label> + <select id="time-period"> + <option value="7day">Past Week (7 days)</option> + <option value="1month">Past Month (30 days)</option> + </select> + </div> + + <div class="form-group"> + <button id="fetch-stats" class="btn-primary">Get Stats</button> + </div> + </div> + + <div id="stats-results" class="stats-results" style="display: none"> + <div id="stats-loading" class="loading" style="display: none"> + <p>Loading your stats...</p> + </div> + + <div id="stats-content" style="display: none"> + <h2>Your Last.fm Stats</h2> + + <div class="stat-box"> + <h3>Total Tracks</h3> + <p id="total-tracks" class="stat-number">-</p> + </div> + + <div class="stat-box"> + <h3>Top 5 Artists</h3> + <ul id="top-artists" class="artist-list"></ul> + </div> + </div> + + <div id="export-buttons" class="export-buttons" style="display: none"> + <button id="copy-markdown" class="btn-export">Copy as Markdown</button> + <button id="copy-plaintext" class="btn-export">Copy as Plain Text</button> + </div> + + <div id="stats-error" class="error" style="display: none"> + <p id="error-message"></p> + </div> + </div> +</div> diff --git a/layouts/shortcodes/lavalamp-adoptable.html b/layouts/shortcodes/lavalamp-adoptable.html new file mode 100644 index 0000000..c256317 --- /dev/null +++ b/layouts/shortcodes/lavalamp-adoptable.html @@ -0,0 +1,124 @@ +<div id="lavalamp-adoptable-app"> + <div class="adoptable-container"> + <div class="preview-section"> + <h3 class="hidden-md-down">Preview</h3> + <div class="preview-grid"> + <div class="preview-item hidden-md-down"> + <div class="preview-wrapper preview-flexible"> + <div + id="lavalamp-preview-flex" + class="lavalamp-preview-container" + ></div> + </div> + <span class="preview-label">40px × 60px</span> + </div> + <div class="preview-item"> + <div class="preview-wrapper preview-100"> + <div + id="lavalamp-preview-100" + class="lavalamp-preview-container" + ></div> + </div> + <span class="preview-label hidden-md-down">100px × 200px</span> + </div> + <div class="preview-item hidden-md-down"> + <div class="preview-wrapper preview-200"> + <div + id="lavalamp-preview-200" + class="lavalamp-preview-container" + ></div> + </div> + <span class="preview-label">200px × 350px</span> + </div> + </div> + </div> + + <div class="controls-section"> + <h3>Customize Your Lava Lamp</h3> + + <div class="control-group"> + <label for="bg-color-1">Background Color 1</label> + <input type="color" id="bg-color-1" value="#F8E45C" /> + </div> + + <div class="control-group"> + <label for="bg-color-2">Background Color 2</label> + <input type="color" id="bg-color-2" value="#FF7800" /> + </div> + + <div class="control-group"> + <label for="blob-color-1">Blob Color 1</label> + <input type="color" id="blob-color-1" value="#FF4500" /> + </div> + + <div class="control-group"> + <label for="blob-color-2">Blob Color 2</label> + <input type="color" id="blob-color-2" value="#FF6347" /> + </div> + + <div class="control-group"> + <label for="case-color">Case Color</label> + <input type="color" id="case-color" value="#333333" /> + </div> + + <div class="control-group"> + <label for="blob-count" + >Number of Blobs: <span id="blob-count-value">6</span></label + > + <input type="range" id="blob-count" min="3" max="12" value="6" /> + </div> + + <div class="control-group"> + <label for="blob-size" + >Blob Size: <span id="blob-size-value">1.0</span>x</label + > + <input + type="range" + id="blob-size" + min="0.5" + max="2.0" + step="0.1" + value="1.0" + /> + </div> + + <div class="control-group"> + <label for="speed">Speed: <span id="speed-value">1.0</span>x</label> + <input + type="range" + id="speed" + min="0.5" + max="1.5" + step="0.1" + value="1.0" + /> + </div> + + <div class="control-group"> + <label> + <input type="checkbox" id="pixelate" /> + Pixelate + </label> + </div> + + <div class="control-group" id="pixel-size-group" style="display: none"> + <label for="pixel-size" + >Pixel Size: <span id="pixel-size-value">4</span>px</label + > + <input type="range" id="pixel-size" min="2" max="10" value="4" /> + </div> + + <div class="control-group"> + <button id="get-code-btn" class="btn-primary">Get Embed Code</button> + </div> + </div> + </div> + + <div id="embed-code-section" style="display: none; margin-top: 2rem"> + <h3>Embed Code</h3> + <p>Copy this code and paste it anywhere on your website:</p> + <div class="highlight"> + <pre><code id="embed-code-display" class="language-html" data-lang="html"></code></pre> + </div> + </div> +</div> diff --git a/send-webmentions.sh b/send-webmentions.sh new file mode 100755 index 0000000..f5ba12e --- /dev/null +++ b/send-webmentions.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +# Load environment variables from .env file +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +else + echo "⚠️ Warning: .env file not found, using defaults" +fi + +# Use environment variables with fallback defaults +WEBMENTIONS_FILE="${WEBMENTIONS_FILE:-public/webmentions.json}" +SENT_CACHE="${SENT_CACHE:-.webmentions-sent}" +API_ENDPOINT="${API_ENDPOINT:-https://api.ritual.sh/webmention/send}" +API_KEY="${API_KEY:-your-secret-key}" + +# Check for dry-run flag +DRY_RUN=false +if [ "$1" = "--dry-run" ] || [ "$1" = "-n" ]; then + DRY_RUN=true + echo "🔍 DRY RUN MODE - No webmentions will be sent" + echo "================================================" +fi + +# Create cache file if it doesn't exist +touch "$SENT_CACHE" + +# Read the webmentions JSON +if [ ! -f "$WEBMENTIONS_FILE" ]; then + echo "No webmentions.json found" + exit 0 +fi + +# Count totals +TOTAL=0 +ALREADY_SENT=0 +TO_SEND=0 + +# Process each link +jq -c '.[]' "$WEBMENTIONS_FILE" | while read -r mention; do + source=$(echo "$mention" | jq -r '.source') + target=$(echo "$mention" | jq -r '.target') + + TOTAL=$((TOTAL + 1)) + + # Create unique key for this source->target pair + key="${source}|${target}" + + # Check if already sent + if grep -Fxq "$key" "$SENT_CACHE"; then + if [ "$DRY_RUN" = true ]; then + echo "⏭️ Already sent: $source -> $target" + else + echo "Already sent: $source -> $target" + fi + ALREADY_SENT=$((ALREADY_SENT + 1)) + continue + fi + + TO_SEND=$((TO_SEND + 1)) + + if [ "$DRY_RUN" = true ]; then + echo "📤 Would send: $source -> $target" + else + echo "Sending webmention: $source -> $target" + + # Send to your API + response=$(curl -s -w "\n%{http_code}" -X POST "$API_ENDPOINT" \ + -H "Content-Type: application/json" \ + -d "{\"auth\":\"$API_KEY\",\"source\":\"$source\",\"target\":\"$target\"}") + + http_code=$(echo "$response" | tail -n1) + + # If successful (200, 201, or 202), add to cache + if [ "$http_code" = "200" ] || [ "$http_code" = "201" ] || [ "$http_code" = "202" ]; then + echo "$key" >> "$SENT_CACHE" + echo "✓ Sent successfully" + else + echo "✗ Failed with status $http_code" + fi + + # Be nice to endpoints - don't spam + sleep 1 + fi +done + +# Summary +echo "" +echo "================================================" +if [ "$DRY_RUN" = true ]; then + echo "🔍 DRY RUN SUMMARY" +else + echo "✅ Webmentions processing complete" +fi +echo "Total links found: $TOTAL" +echo "Already sent: $ALREADY_SENT" +echo "To send: $TO_SEND" \ No newline at end of file diff --git a/static/js/adoptables/lavalamp.js b/static/js/adoptables/lavalamp.js new file mode 100644 index 0000000..6c55277 --- /dev/null +++ b/static/js/adoptables/lavalamp.js @@ -0,0 +1,364 @@ +(function () { + "use strict"; + + const currentScript = document.currentScript; + if (!currentScript) { + console.error("Lava Lamp: Could not find current script tag"); + return; + } + + // Read configuration from data attributes with defaults + const config = { + bgColor1: currentScript.dataset["bgColor-1"] || "#F8E45C", + bgColor2: currentScript.dataset["bgColor-2"] || "#FF7800", + blobColor1: currentScript.dataset["blobColor-1"] || "#FF4500", + blobColor2: currentScript.dataset["blobColor-2"] || "#FF6347", + caseColor: currentScript.dataset.caseColor || "#333333", + blobCount: parseInt(currentScript.dataset.blobCount) || 6, + speed: parseFloat(currentScript.dataset.speed) || 1.0, + blobSize: parseFloat(currentScript.dataset.blobSize) || 1.0, + pixelate: currentScript.dataset.pixelate === "true" || false, + pixelSize: parseInt(currentScript.dataset.pixelSize) || 4, + }; + + // Helper function to adjust color brightness + function adjustBrightness(color, percent) { + const num = parseInt(color.replace("#", ""), 16); + const amt = Math.round(2.55 * percent); + const R = (num >> 16) + amt; + const G = ((num >> 8) & 0x00ff) + amt; + const B = (num & 0x0000ff) + amt; + return ( + "#" + + ( + 0x1000000 + + (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + + (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + + (B < 255 ? (B < 1 ? 0 : B) : 255) + ) + .toString(16) + .slice(1) + ); + } + + // Create a host element with shadow DOM for isolation + const host = document.createElement("div"); + host.style.width = "100%"; + host.style.height = "100%"; + host.style.display = "block"; + + if (config.pixelate) { + host.style.overflow = "hidden"; + } + + // Attach shadow DOM + const shadowRoot = host.attachShadow({ mode: "open" }); + + currentScript.parentNode.insertBefore(host, currentScript.nextSibling); + + const gooFilterId = "goo-filter"; + + // Inject CSS into shadow DOM + const style = document.createElement("style"); + style.textContent = ` + .lavalamp-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + ${config.pixelate ? "filter: url(#pixelate-filter);" : ""} + } + + .lamp-cap { + width: 60%; + height: 8%; + flex-shrink: 0; + border-radius: 50% 50% 0 0; + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.3); + position: relative; + z-index: 10; + } + + .lamp-body { + position: relative; + width: 100%; + flex: 1; + clip-path: polygon(20% 0, 80% 0, 100% 101%, 0% 101%); + overflow: hidden; + } + + .lamp-body::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.15) 20%, + rgba(255, 255, 255, 0.05) 40%, + transparent 60% + ); + pointer-events: none; + } + + .blobs-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + filter: url(#${gooFilterId}); + pointer-events: none; + z-index: 2; + } + + .blob { + position: absolute; + border-radius: 50%; + animation: lavalamp-float var(--duration) ease-in-out infinite; + opacity: 0.95; + mix-blend-mode: normal; + z-index: 3; + } + + .lamp-base { + width: 100%; + height: 15%; + flex-shrink: 0; + border-radius: 0 0 50% 50%; + box-shadow: + inset 0 2px 5px rgba(255, 255, 255, 0.2), + inset 0 -2px 5px rgba(0, 0, 0, 0.5); + position: relative; + display: flex; + justify-content: center; + align-items: center; + } + + @keyframes lavalamp-float { + 0%, 100% { + transform: translate(var(--start-x), var(--start-y)) scale(1); + } + 25% { + transform: translate(var(--mid1-x), var(--mid1-y)) scale(var(--scale1, 1.1)); + } + 50% { + transform: translate(var(--mid2-x), var(--mid2-y)) scale(var(--scale2, 0.9)); + } + 75% { + transform: translate(var(--mid3-x), var(--mid3-y)) scale(var(--scale3, 1.05)); + } + } + `; + shadowRoot.appendChild(style); + + // Create the HTML structure + const container = document.createElement("div"); + container.className = "lavalamp-container"; + + // SVG filters for goo effect and pixelation + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.style.position = "absolute"; + svg.style.width = "0"; + svg.style.height = "0"; + svg.innerHTML = ` + <defs> + <filter id="${gooFilterId}"> + <feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" /> + <feColorMatrix + in="blur" + mode="matrix" + values=" + 1 0 0 0 0 + 0 1 0 0 0 + 0 0 1 0 0 + 0 0 0 18 -7" + result="goo" + /> + <feComposite in="SourceGraphic" in2="goo" operator="atop" /> + </filter> + ${ + config.pixelate + ? ` + <filter id="pixelate-filter" x="0%" y="0%" width="100%" height="100%"> + <feFlood x="0" y="0" height="1" width="1"/> + <feComposite width="${config.pixelSize}" height="${config.pixelSize}"/> + <feTile result="a"/> + <feComposite in="SourceGraphic" in2="a" operator="in"/> + <feMorphology operator="dilate" radius="${Math.floor(config.pixelSize / 2)}"/> + </filter> + ` + : "" + } + </defs> + `; + + const lampCap = document.createElement("div"); + lampCap.className = "lamp-cap"; + lampCap.style.background = `linear-gradient(180deg, ${adjustBrightness(config.caseColor, 40)} 0%, ${config.caseColor} 50%, ${adjustBrightness(config.caseColor, -20)} 100%)`; + + const lampBody = document.createElement("div"); + lampBody.className = "lamp-body"; + lampBody.style.background = `linear-gradient(180deg, ${config.bgColor1} 0%, ${config.bgColor2} 100%)`; + + const blobsContainer = document.createElement("div"); + blobsContainer.className = "blobs-container"; + + const lampBase = document.createElement("div"); + lampBase.className = "lamp-base"; + lampBase.style.background = `linear-gradient(180deg, ${config.caseColor} 0%, ${adjustBrightness(config.caseColor, -20)} 40%, ${adjustBrightness(config.caseColor, -40)} 100%)`; + lampBase.style.borderTop = `1px solid ${adjustBrightness(config.bgColor2, -30)}`; + + // Assemble the structure + lampBody.appendChild(blobsContainer); + container.appendChild(svg); + container.appendChild(lampCap); + container.appendChild(lampBody); + container.appendChild(lampBase); + + // Append to shadow DOM + shadowRoot.appendChild(container); + + // Blob creation and animation + let blobs = []; + + function createBlob() { + const blob = document.createElement("div"); + blob.className = "blob"; + + // Get container dimensions + const containerWidth = lampBody.offsetWidth; + const containerHeight = lampBody.offsetHeight; + + // Size relative to container width (25-40% of width) + const size = + (Math.random() * 0.15 + 0.25) * containerWidth * config.blobSize; + const duration = (Math.random() * 8 + 12) / config.speed; + + // Max X position accounting for blob size and container margins + const maxX = Math.max(0, containerWidth - size); + const startX = maxX > 0 ? Math.random() * maxX : 0; + + // Create gradient for blob + const blobGradient = `radial-gradient(circle at 30% 30%, ${config.blobColor1}, ${config.blobColor2})`; + + blob.style.width = `${size}px`; + blob.style.height = `${size}px`; + blob.style.left = `${startX}px`; + blob.style.bottom = "10px"; + blob.style.position = "absolute"; + blob.style.background = blobGradient; + blob.style.setProperty("--duration", `${duration}s`); + blob.style.setProperty("--start-x", "0px"); + blob.style.setProperty("--start-y", "0px"); + + // Movement waypoints + blob.style.setProperty( + "--mid1-x", + `${(Math.random() * 0.3 - 0.15) * containerWidth}px`, + ); + blob.style.setProperty( + "--mid1-y", + `${-(Math.random() * 0.15 + 0.25) * containerHeight}px`, + ); + + blob.style.setProperty( + "--mid2-x", + `${(Math.random() * 0.4 - 0.2) * containerWidth}px`, + ); + blob.style.setProperty( + "--mid2-y", + `${-(Math.random() * 0.2 + 0.5) * containerHeight}px`, + ); + + blob.style.setProperty( + "--mid3-x", + `${(Math.random() * 0.3 - 0.15) * containerWidth}px`, + ); + blob.style.setProperty( + "--mid3-y", + `${-(Math.random() * 0.15 + 0.8) * containerHeight}px`, + ); + + // Scale variations + blob.style.setProperty("--scale1", (Math.random() * 0.3 + 1.0).toFixed(2)); + blob.style.setProperty("--scale2", (Math.random() * 0.3 + 0.85).toFixed(2)); + blob.style.setProperty("--scale3", (Math.random() * 0.3 + 0.95).toFixed(2)); + + // Random delay to stagger animations + blob.style.animationDelay = `${Math.random() * -20}s`; + + return blob; + } + + function updateBlobCount() { + while (blobs.length > config.blobCount) { + const blob = blobs.pop(); + blobsContainer.removeChild(blob); + } + while (blobs.length < config.blobCount) { + const blob = createBlob(); + blobs.push(blob); + blobsContainer.appendChild(blob); + } + } + + // Initialize + function init() { + // Wait for container to have dimensions + // Not sure if this is a great idea, what if this never happens for some reason + // This is as good as I can come up with right now + if (lampBody.offsetWidth === 0 || lampBody.offsetHeight === 0) { + setTimeout(init, 100); + return; + } + + // Scale goo filter blur based on container width + const containerWidth = lampBody.offsetWidth; + + // Apply scaled goo filter + let blurAmount, alphaMultiplier, alphaBias; + + if (containerWidth < 80) { + blurAmount = 3; + alphaMultiplier = 12; + alphaBias = -5; + } else { + blurAmount = Math.max(4, Math.min(7, containerWidth / 20)); + alphaMultiplier = 18; + alphaBias = -7; + } + + const gooFilter = svg.querySelector(`#${gooFilterId} feGaussianBlur`); + if (gooFilter) { + gooFilter.setAttribute("stdDeviation", blurAmount); + } + + const colorMatrix = svg.querySelector(`#${gooFilterId} feColorMatrix`); + if (colorMatrix) { + colorMatrix.setAttribute( + "values", + ` + 1 0 0 0 0 + 0 1 0 0 0 + 0 0 1 0 0 + 0 0 0 ${alphaMultiplier} ${alphaBias} + `, + ); + } + + updateBlobCount(); + } + + // Start when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/static/js/button-generator/button-generator-core.js b/static/js/button-generator/button-generator-core.js new file mode 100644 index 0000000..292bc4f --- /dev/null +++ b/static/js/button-generator/button-generator-core.js @@ -0,0 +1,434 @@ +import { ButtonEffect } from './effect-base.js'; + +import { ColorQuantizer } from './color-quantizer.js'; + +/** + * Animation state class - passed to effects for frame-based rendering + */ +export class AnimationState { + constructor(frameNumber = 0, totalFrames = 40, fps = 20) { + this.frame = frameNumber; + this.totalFrames = totalFrames; + this.progress = totalFrames > 1 ? frameNumber / (totalFrames - 1) : 0; // 0 to 1, inclusive of last frame + this.fps = fps; + this.time = (frameNumber / fps) * 1000; // milliseconds + } + + /** + * Helper to get phase for periodic animations (0 to 2π) + * @param {number} speed - Speed multiplier + * @returns {number} Phase in radians + */ + getPhase(speed = 1.0) { + return this.progress * speed * Math.PI * 2; + } +} + +/** + * Main ButtonGenerator class with effect registry system + */ +export class ButtonGenerator { + constructor(canvas, config = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + // Animation configuration + this.animConfig = { + fps: config.fps || 20, + duration: config.duration || 2, // seconds + get totalFrames() { + return this.fps * this.duration; + } + }; + + // GIF export configuration + this.gifConfig = { + quality: config.gifQuality || 1, // 1-30, lower is better quality quantization + dither: config.gifDither || false, // false, 'FloydSteinberg', 'FalseFloydSteinberg', 'Stucki', 'Atkinson' + colorCount: config.gifColorCount || 256, // 2-256, number of colors to reduce to (custom quantization) + }; + + // Effect registry organized by type + this.effects = { + transform: [], + background: [], + border: [], + text: [], + text2: [], + general: [] + }; + + // Registered effects by ID for quick lookup + this.effectsById = new Map(); + + // Control elements cache + this.controlElements = {}; + + // Animation state + this.previewAnimationId = null; + + // Font list for preloading + this.fonts = config.fonts || [ + 'Lato', 'Roboto', 'Open Sans', 'Montserrat', 'Oswald', + 'Bebas Neue', 'Roboto Mono', 'VT323', 'Press Start 2P', 'DSEG7-Classic' + ]; + } + + /** + * Register an effect with the generator + * @param {ButtonEffect} effect - Effect instance to register + */ + registerEffect(effect) { + if (!(effect instanceof ButtonEffect)) { + throw new Error('Effect must extend ButtonEffect class'); + } + + if (this.effectsById.has(effect.id)) { + console.warn(`Effect with ID "${effect.id}" is already registered. Skipping.`); + return; + } + + // Add to type-specific array + const type = effect.type; + if (!this.effects[type]) { + this.effects[type] = []; + } + + this.effects[type].push(effect); + this.effectsById.set(effect.id, effect); + + // Sort by render order + this.effects[type].sort((a, b) => a.renderOrder - b.renderOrder); + + console.log(`Registered effect: ${effect.name} (${effect.id}) [${type}]`); + } + + /** + * Get all registered effects + * @returns {Array<ButtonEffect>} + */ + getAllEffects() { + return Array.from(this.effectsById.values()); + } + + /** + * Get effects by type + * @param {string} type - Effect type + * @returns {Array<ButtonEffect>} + */ + getEffectsByType(type) { + return this.effects[type] || []; + } + + /** + * Initialize and preload fonts + * @returns {Promise} + */ + async preloadFonts() { + const fontPromises = this.fonts.flatMap(font => [ + document.fonts.load(`400 12px "${font}"`), + document.fonts.load(`700 12px "${font}"`), + document.fonts.load(`italic 400 12px "${font}"`) + ]); + + await Promise.all(fontPromises); + console.log('All fonts loaded for canvas'); + } + + /** + * Get current control values from DOM + * @returns {Object} Map of control ID to value + */ + getControlValues() { + const values = {}; + + // Get all registered control IDs from effects + const allControls = new Set(); + this.getAllEffects().forEach(effect => { + effect.controls.forEach(control => { + allControls.add(control.id); + }); + }); + + // Read values from DOM + allControls.forEach(id => { + const element = document.getElementById(id); + if (element) { + if (element.type === 'checkbox') { + values[id] = element.checked; + } else if (element.type === 'range' || element.type === 'number') { + values[id] = parseFloat(element.value); + } else if (element.type === 'file') { + // For file inputs, return an object with file metadata and blob URL + if (element.dataset.blobUrl) { + values[id] = { + fileName: element.dataset.fileName, + blobUrl: element.dataset.blobUrl, + fileSize: parseInt(element.dataset.fileSize), + fileType: element.dataset.fileType + }; + } else { + values[id] = null; + } + } else { + values[id] = element.value; + } + } + }); + + return values; + } + + /** + * Draw button with all effects applied + * @param {AnimationState} animState - Animation state (null for static) + * @param {Object} baseControls - Base button controls (text, colors, etc.) + */ + draw(animState = null, baseControls = {}) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + const controlValues = { ...baseControls, ...this.getControlValues() }; + const renderData = { + width: this.canvas.width, + height: this.canvas.height, + centerX: this.canvas.width / 2, + centerY: this.canvas.height / 2 + }; + + // Apply effects in order: transform -> background -> background-animation -> text/text2 -> border -> general + const renderOrder = ['transform', 'background', 'background-animation', 'text', 'text2', 'border', 'general']; + + // Save context once before transforms + this.ctx.save(); + + renderOrder.forEach(type => { + this.effects[type]?.forEach(effect => { + if (effect.canApply(controlValues)) { + // Transform effects should NOT be wrapped in save/restore + // They need to persist for all subsequent drawing operations + if (type !== 'transform') { + this.ctx.save(); + } + + try { + effect.apply(this.ctx, controlValues, animState, renderData); + } catch (error) { + console.error(`Error applying effect ${effect.id}:`, error); + } + + if (type !== 'transform') { + this.ctx.restore(); + } + } + }); + }); + + // Restore context once after all drawing + this.ctx.restore(); + } + + /** + * Check if any animations are enabled + * @returns {boolean} + */ + hasAnimationsEnabled() { + const controlValues = this.getControlValues(); + return this.getAllEffects().some(effect => + effect.type !== 'background' && // Background effects can be static + effect.isEnabled(controlValues) + ); + } + + /** + * Start animated preview loop + */ + startAnimatedPreview() { + this.stopAnimatedPreview(); + + let frameNum = 0; + let lastFrameTime = performance.now(); + const frameDelay = 1000 / this.animConfig.fps; + + const animate = (currentTime) => { + const elapsed = currentTime - lastFrameTime; + + if (elapsed >= frameDelay) { + const animState = new AnimationState( + frameNum, + this.animConfig.totalFrames, + this.animConfig.fps + ); + this.draw(animState); + this.applyPreviewQuantization(); + + frameNum = (frameNum + 1) % this.animConfig.totalFrames; + lastFrameTime = currentTime - (elapsed % frameDelay); + } + + this.previewAnimationId = requestAnimationFrame(animate); + }; + + this.previewAnimationId = requestAnimationFrame(animate); + } + + /** + * Stop animated preview + */ + stopAnimatedPreview() { + if (this.previewAnimationId) { + cancelAnimationFrame(this.previewAnimationId); + this.previewAnimationId = null; + } + } + + /** + * Update preview (static or animated based on settings) + */ + updatePreview() { + if (this.hasAnimationsEnabled()) { + this.startAnimatedPreview(); + } else { + this.stopAnimatedPreview(); + this.draw(); + this.applyPreviewQuantization(); + } + } + + /** + * Apply color quantization to preview if enabled + */ + applyPreviewQuantization() { + const colorCount = this.gifConfig.colorCount; + if (colorCount < 256) { + const quantizedData = ColorQuantizer.quantize(this.canvas, colorCount, 'floyd-steinberg'); + this.ctx.putImageData(quantizedData, 0, 0); + } + } + + /** + * Export as animated GIF + * @param {Function} progressCallback - Called with progress (0-1) + * @param {Object} options - Export options + * @param {number} options.quality - Quality (1-30, lower is better, default: 10) + * @param {boolean|string} options.dither - Dithering algorithm for gif.js + * @param {number} options.colorCount - Number of colors (2-256, default: 256) - uses custom quantization + * @returns {Promise<Blob>} + */ + async exportAsGif(progressCallback = null, options = {}) { + return new Promise((resolve, reject) => { + try { + // Create temporary canvas for frame generation + const frameCanvas = document.createElement('canvas'); + frameCanvas.width = this.canvas.width; + frameCanvas.height = this.canvas.height; + const frameCtx = frameCanvas.getContext('2d'); + + // Merge options with defaults + const quality = options.quality !== undefined ? options.quality : this.gifConfig.quality; + const gifDither = options.dither !== undefined ? options.dither : this.gifConfig.dither; + const colorCount = options.colorCount !== undefined ? options.colorCount : this.gifConfig.colorCount; + + // Determine if we need custom quantization + const useCustomQuantization = colorCount < 256; + const customDither = useCustomQuantization ? 'floyd-steinberg' : false; + + // Initialize gif.js + const gifOptions = { + workers: 2, + quality: quality, + workerScript: '/js/gif.worker.js', + width: this.canvas.width, + height: this.canvas.height + }; + + // Add gif.js dither option if specified (only when not using custom quantization) + if (!useCustomQuantization && gifDither !== false) { + gifOptions.dither = gifDither; + } + + const gif = new GIF(gifOptions); + + // Generate frames + const totalFrames = this.animConfig.totalFrames; + + const generateFrames = async () => { + for (let i = 0; i < totalFrames; i++) { + const animState = new AnimationState(i, totalFrames, this.animConfig.fps); + + // Draw to temporary canvas + frameCtx.clearRect(0, 0, frameCanvas.width, frameCanvas.height); + const tempGenerator = new ButtonGenerator(frameCanvas, { + fps: this.animConfig.fps, + duration: this.animConfig.duration, + fonts: this.fonts + }); + + // Copy effects + this.getAllEffects().forEach(effect => { + tempGenerator.registerEffect(effect); + }); + + tempGenerator.draw(animState); + + // Apply custom color quantization if needed + if (useCustomQuantization) { + const quantizedData = ColorQuantizer.quantize(frameCanvas, colorCount, customDither); + frameCtx.putImageData(quantizedData, 0, 0); + } + + gif.addFrame(frameCtx, { + delay: 1000 / this.animConfig.fps, + copy: true + }); + + if (progressCallback) { + progressCallback(i / totalFrames, 'generating'); + } + + // Yield to browser every 5 frames + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + }; + + generateFrames().then(() => { + gif.on('finished', (blob) => { + resolve(blob); + }); + + gif.on('progress', (progress) => { + if (progressCallback) { + progressCallback(progress, 'encoding'); + } + }); + + gif.render(); + }); + + } catch (error) { + reject(error); + } + }); + } + + /** + * Bind UI controls to redraw on change + */ + bindControls() { + const allControls = new Set(); + this.getAllEffects().forEach(effect => { + effect.controls.forEach(control => { + allControls.add(control.id); + }); + }); + + allControls.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.addEventListener('input', () => this.updatePreview()); + element.addEventListener('change', () => this.updatePreview()); + } + }); + } +} diff --git a/static/js/button-generator/color-quantizer.js b/static/js/button-generator/color-quantizer.js new file mode 100644 index 0000000..f2343ba --- /dev/null +++ b/static/js/button-generator/color-quantizer.js @@ -0,0 +1,263 @@ +/** + * Color Quantizer - Custom color reduction with median cut algorithm + * Reduces canvas colors with optional dithering for retro aesthetic + */ + +export class ColorQuantizer { + /** + * Reduce colors in a canvas using median cut algorithm + * @param {HTMLCanvasElement} canvas - Canvas to quantize + * @param {number} colorCount - Target number of colors (2-256) + * @param {string|boolean} dither - Dithering algorithm ('floyd-steinberg', false) + * @returns {ImageData} Quantized image data + */ + static quantize(canvas, colorCount = 256, dither = false) { + const ctx = canvas.getContext('2d'); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; + + if (colorCount >= 256) { + return imageData; // No quantization needed + } + + // Build palette using median cut + const palette = this.buildPalette(pixels, colorCount); + + // Apply palette to image + if (dither === 'floyd-steinberg') { + this.applyPaletteWithDithering(pixels, palette, canvas.width, canvas.height); + } else { + this.applyPalette(pixels, palette); + } + + return imageData; + } + + /** + * Build color palette using median cut algorithm + */ + static buildPalette(pixels, colorCount) { + // Collect unique colors + const colorMap = new Map(); + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const a = pixels[i + 3]; + + if (a === 0) continue; // Skip transparent pixels + + const key = (r << 16) | (g << 8) | b; + colorMap.set(key, (colorMap.get(key) || 0) + 1); + } + + // Convert to array of color objects with counts + const colors = Array.from(colorMap.entries()).map(([key, count]) => ({ + r: (key >> 16) & 0xFF, + g: (key >> 8) & 0xFF, + b: key & 0xFF, + count: count + })); + + // If we have fewer colors than target, return as-is + if (colors.length <= colorCount) { + return colors.map(c => [c.r, c.g, c.b]); + } + + // Start with all colors in one bucket + let buckets = [colors]; + + // Split buckets until we have desired number + while (buckets.length < colorCount) { + // Find bucket with largest range + let maxRange = -1; + let maxBucketIdx = 0; + let maxChannel = 'r'; + + buckets.forEach((bucket, idx) => { + if (bucket.length <= 1) return; + + const ranges = this.getColorRanges(bucket); + const range = Math.max(ranges.r, ranges.g, ranges.b); + + if (range > maxRange) { + maxRange = range; + maxBucketIdx = idx; + if (ranges.r >= ranges.g && ranges.r >= ranges.b) maxChannel = 'r'; + else if (ranges.g >= ranges.b) maxChannel = 'g'; + else maxChannel = 'b'; + } + }); + + if (maxRange === -1) break; // Can't split further + + // Split the bucket + const bucket = buckets[maxBucketIdx]; + bucket.sort((a, b) => a[maxChannel] - b[maxChannel]); + + const mid = Math.floor(bucket.length / 2); + const bucket1 = bucket.slice(0, mid); + const bucket2 = bucket.slice(mid); + + buckets.splice(maxBucketIdx, 1, bucket1, bucket2); + } + + // Average colors in each bucket to create palette + return buckets.map(bucket => { + let totalWeight = 0; + let sumR = 0, sumG = 0, sumB = 0; + + bucket.forEach(color => { + const weight = color.count; + totalWeight += weight; + sumR += color.r * weight; + sumG += color.g * weight; + sumB += color.b * weight; + }); + + return [ + Math.round(sumR / totalWeight), + Math.round(sumG / totalWeight), + Math.round(sumB / totalWeight) + ]; + }); + } + + /** + * Get color ranges in a bucket + */ + static getColorRanges(bucket) { + let minR = 255, maxR = 0; + let minG = 255, maxG = 0; + let minB = 255, maxB = 0; + + bucket.forEach(color => { + minR = Math.min(minR, color.r); + maxR = Math.max(maxR, color.r); + minG = Math.min(minG, color.g); + maxG = Math.max(maxG, color.g); + minB = Math.min(minB, color.b); + maxB = Math.max(maxB, color.b); + }); + + return { + r: maxR - minR, + g: maxG - minG, + b: maxB - minB + }; + } + + /** + * Find nearest color in palette + */ + static findNearestColor(r, g, b, palette) { + let minDist = Infinity; + let nearest = palette[0]; + + for (const color of palette) { + const dr = r - color[0]; + const dg = g - color[1]; + const db = b - color[2]; + const dist = dr * dr + dg * dg + db * db; + + if (dist < minDist) { + minDist = dist; + nearest = color; + } + } + + return nearest; + } + + /** + * Apply palette without dithering + */ + static applyPalette(pixels, palette) { + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const a = pixels[i + 3]; + + if (a === 0) continue; + + const nearest = this.findNearestColor(r, g, b, palette); + pixels[i] = nearest[0]; + pixels[i + 1] = nearest[1]; + pixels[i + 2] = nearest[2]; + } + } + + /** + * Apply palette with Floyd-Steinberg dithering + */ + static applyPaletteWithDithering(pixels, palette, width, height) { + // Create error buffer + const errors = new Float32Array(width * height * 3); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const errIdx = (y * width + x) * 3; + + const r = pixels[idx]; + const g = pixels[idx + 1]; + const b = pixels[idx + 2]; + const a = pixels[idx + 3]; + + if (a === 0) continue; + + // Add accumulated error + const newR = Math.max(0, Math.min(255, r + errors[errIdx])); + const newG = Math.max(0, Math.min(255, g + errors[errIdx + 1])); + const newB = Math.max(0, Math.min(255, b + errors[errIdx + 2])); + + // Find nearest palette color + const nearest = this.findNearestColor(newR, newG, newB, palette); + + // Set pixel to nearest color + pixels[idx] = nearest[0]; + pixels[idx + 1] = nearest[1]; + pixels[idx + 2] = nearest[2]; + + // Calculate error + const errR = newR - nearest[0]; + const errG = newG - nearest[1]; + const errB = newB - nearest[2]; + + // Distribute error to neighboring pixels (Floyd-Steinberg) + // Right pixel (x+1, y): 7/16 + if (x + 1 < width) { + const rightIdx = (y * width + (x + 1)) * 3; + errors[rightIdx] += errR * 7 / 16; + errors[rightIdx + 1] += errG * 7 / 16; + errors[rightIdx + 2] += errB * 7 / 16; + } + + // Bottom-left pixel (x-1, y+1): 3/16 + if (y + 1 < height && x > 0) { + const blIdx = ((y + 1) * width + (x - 1)) * 3; + errors[blIdx] += errR * 3 / 16; + errors[blIdx + 1] += errG * 3 / 16; + errors[blIdx + 2] += errB * 3 / 16; + } + + // Bottom pixel (x, y+1): 5/16 + if (y + 1 < height) { + const bottomIdx = ((y + 1) * width + x) * 3; + errors[bottomIdx] += errR * 5 / 16; + errors[bottomIdx + 1] += errG * 5 / 16; + errors[bottomIdx + 2] += errB * 5 / 16; + } + + // Bottom-right pixel (x+1, y+1): 1/16 + if (y + 1 < height && x + 1 < width) { + const brIdx = ((y + 1) * width + (x + 1)) * 3; + errors[brIdx] += errR * 1 / 16; + errors[brIdx + 1] += errG * 1 / 16; + errors[brIdx + 2] += errB * 1 / 16; + } + } + } + } +} diff --git a/static/js/button-generator/debug-helper.js b/static/js/button-generator/debug-helper.js new file mode 100644 index 0000000..8368296 --- /dev/null +++ b/static/js/button-generator/debug-helper.js @@ -0,0 +1,25 @@ +// Debug helper - add this temporarily to main.js to see what's happening + +export function debugControlValues(generator) { + console.log('=== DEBUG: Control Values ==='); + const values = generator.getControlValues(); + + // Text controls + console.log('Text controls:'); + console.log(' button-text:', values['button-text']); + console.log(' font-size:', values['font-size']); + console.log(' text-x:', values['text-x']); + console.log(' text-y:', values['text-y']); + + // Check which effects are enabled + console.log('\nEnabled effects:'); + generator.getAllEffects().forEach(effect => { + const enabled = effect.isEnabled(values); + if (enabled) { + console.log(` ✓ ${effect.name} (${effect.id})`); + } + }); + + console.log('\nAll control values:', values); + console.log('=== END DEBUG ==='); +} diff --git a/static/js/button-generator/effect-base.js b/static/js/button-generator/effect-base.js new file mode 100644 index 0000000..87489cc --- /dev/null +++ b/static/js/button-generator/effect-base.js @@ -0,0 +1,92 @@ +/** + * Base class for button generator effects + * All effects should extend this class and implement required methods + */ +export class ButtonEffect { + /** + * @param {Object} config - Effect configuration + * @param {string} config.id - Unique identifier for this effect + * @param {string} config.name - Display name for the effect + * @param {string} config.type - Effect type: 'text', 'text2', 'background', 'general', 'border' + * @param {string} config.category - UI category for grouping effects + * @param {number} config.renderOrder - Order in rendering pipeline (lower = earlier) + */ + constructor(config) { + this.config = config; // Store full config for subclasses to access + this.id = config.id; + this.name = config.name; + this.type = config.type; // 'text', 'text2', 'background', 'general', 'border' + this.category = config.category; + this.renderOrder = config.renderOrder || 100; + this.controls = this.defineControls(); + } + + /** + * Define UI controls for this effect + * @returns {Array<Object>} Array of control definitions + * + * Control definition format: + * { + * id: string, // HTML element ID + * type: 'checkbox' | 'range' | 'color' | 'select' | 'text', + * label: string, // Display label + * defaultValue: any, // Default value + * min: number, // For range controls + * max: number, // For range controls + * step: number, // For range controls + * options: Array<{value, label}>, // For select controls + * showWhen: string, // ID of checkbox that controls visibility + * description: string // Optional tooltip/help text + * } + */ + defineControls() { + return []; + } + + /** + * Check if this effect is currently enabled + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + isEnabled(controlValues) { + // Default: check for a control with ID pattern 'animate-{effectId}' or '{effectId}-enabled' + const enableControl = controlValues[`animate-${this.id}`] || + controlValues[`${this.id}-enabled`]; + return enableControl === true || enableControl === 'true'; + } + + /** + * Apply the effect during rendering + * @param {CanvasRenderingContext2D} context - Canvas context to draw on + * @param {Object} controlValues - Current values of all controls + * @param {AnimationState} animState - Current animation state (null for static) + * @param {Object} renderData - Additional render data (text metrics, colors, etc.) + */ + apply(context, controlValues, animState, renderData) { + throw new Error('Effect.apply() must be implemented by subclass'); + } + + /** + * Get control values specific to this effect + * @param {Object} allControlValues - All control values + * @returns {Object} Only the controls relevant to this effect + */ + getEffectControls(allControlValues) { + const effectControls = {}; + this.controls.forEach(control => { + if (control.id in allControlValues) { + effectControls[control.id] = allControlValues[control.id]; + } + }); + return effectControls; + } + + /** + * Validate that this effect can be applied + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + canApply(controlValues) { + return this.isEnabled(controlValues); + } +} diff --git a/static/js/button-generator/effects/EXAMPLE.js b/static/js/button-generator/effects/EXAMPLE.js new file mode 100644 index 0000000..c2126d0 --- /dev/null +++ b/static/js/button-generator/effects/EXAMPLE.js @@ -0,0 +1,205 @@ +/** + * EXAMPLE EFFECT + * + * This is a template for creating new effects. + * Copy this file and modify it to create your own custom effects. + * + * This example creates a "spotlight" effect that highlights a circular area + * and darkens the rest of the button. + */ + +import { ButtonEffect } from '../effect-base.js'; + +/** + * Spotlight Effect + * Creates a moving circular spotlight that highlights different areas + */ +export class SpotlightEffect extends ButtonEffect { + constructor() { + super({ + // Unique ID for this effect (used in control IDs) + id: 'spotlight', + + // Display name shown in UI + name: 'Spotlight', + + // Effect type determines render order category + // Options: 'background', 'border', 'text', 'text2', 'general' + type: 'general', + + // Category for organizing effects in UI + category: 'Visual Effects', + + // Render order within type (lower = earlier) + // 1-9: backgrounds, 10-19: borders, 20-29: transforms, + // 30-49: text, 50-79: overlays, 80-99: post-processing + renderOrder: 60 + }); + } + + /** + * Define UI controls for this effect + * These controls will be automatically bound to the generator + */ + defineControls() { + return [ + // Main enable/disable checkbox + { + id: 'animate-spotlight', + type: 'checkbox', + label: 'Spotlight Effect', + defaultValue: false, + description: 'Moving circular spotlight' + }, + + // Spotlight size control + { + id: 'spotlight-size', + type: 'range', + label: 'Spotlight Size', + defaultValue: 20, + min: 10, + max: 50, + step: 1, + showWhen: 'animate-spotlight', // Only show when checkbox is enabled + description: 'Radius of the spotlight' + }, + + // Darkness of the vignette + { + id: 'spotlight-darkness', + type: 'range', + label: 'Darkness', + defaultValue: 0.5, + min: 0, + max: 1, + step: 0.05, + showWhen: 'animate-spotlight', + description: 'How dark the non-spotlight area should be' + }, + + // Speed of movement + { + id: 'spotlight-speed', + type: 'range', + label: 'Movement Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: 'animate-spotlight', + description: 'Speed of spotlight movement' + } + ]; + } + + /** + * Determine if this effect should be applied + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + isEnabled(controlValues) { + return controlValues['animate-spotlight'] === true; + } + + /** + * Apply the effect to the canvas + * + * @param {CanvasRenderingContext2D} context - Canvas 2D rendering context + * @param {Object} controlValues - Current values of all controls + * @param {AnimationState|null} animState - Animation state (null for static render) + * @param {Object} renderData - Render information: { width, height, centerX, centerY } + */ + apply(context, controlValues, animState, renderData) { + // Skip if no animation (spotlight needs movement) + if (!animState) return; + + // Get control values + const size = controlValues['spotlight-size'] || 20; + const darkness = controlValues['spotlight-darkness'] || 0.5; + const speed = controlValues['spotlight-speed'] || 1; + + // Calculate spotlight position + // Move in a circular pattern using animation phase + const phase = animState.getPhase(speed); + const spotX = renderData.centerX + Math.cos(phase) * 20; + const spotY = renderData.centerY + Math.sin(phase) * 10; + + // Create radial gradient for spotlight effect + const gradient = context.createRadialGradient( + spotX, spotY, 0, // Inner circle (center of spotlight) + spotX, spotY, size // Outer circle (edge of spotlight) + ); + + // Center is transparent (spotlight is bright) + gradient.addColorStop(0, `rgba(0, 0, 0, 0)`); + // Edge fades to dark + gradient.addColorStop(0.5, `rgba(0, 0, 0, ${darkness * 0.3})`); + gradient.addColorStop(1, `rgba(0, 0, 0, ${darkness})`); + + // Apply the gradient as an overlay + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Optional: Add a bright center dot + context.fillStyle = 'rgba(255, 255, 255, 0.3)'; + context.beginPath(); + context.arc(spotX, spotY, 2, 0, Math.PI * 2); + context.fill(); + } + + /** + * Optional: Override canApply for more complex logic + * By default, it just checks isEnabled() + */ + canApply(controlValues) { + // Example: Only apply if text is also enabled + const textEnabled = controlValues['textEnabled']; + return this.isEnabled(controlValues) && textEnabled; + } + + /** + * Optional: Add helper methods for your effect + */ + calculateSpotlightPath(progress, width, height) { + // Example helper method + return { + x: width * progress, + y: height / 2 + }; + } +} + +/** + * Registration function + * This is called to add the effect to the generator + * + * @param {ButtonGenerator} generator - The button generator instance + */ +export function register(generator) { + generator.registerEffect(new SpotlightEffect()); +} + +/** + * USAGE: + * + * 1. Copy this file to a new name (e.g., my-effect.js) + * 2. Modify the class name, id, and effect logic + * 3. Import in main.js: + * import * as myEffect from './effects/my-effect.js'; + * 4. Register in setupApp(): + * myEffect.register(generator); + * 5. Add HTML controls with matching IDs + */ + +/** + * TIPS: + * + * - Use animState.progress for linear animations (0 to 1) + * - Use animState.getPhase(speed) for periodic animations (0 to 2π) + * - Use Math.sin/cos for smooth periodic motion + * - Check if (!animState) at the start if your effect requires animation + * - The context is automatically saved/restored, so feel free to transform + * - Use renderData for canvas dimensions and center point + * - Look at existing effects for more examples + */ diff --git a/static/js/button-generator/effects/background-aurora.js b/static/js/button-generator/effects/background-aurora.js new file mode 100644 index 0000000..2afad33 --- /dev/null +++ b/static/js/button-generator/effects/background-aurora.js @@ -0,0 +1,191 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Aurora/Plasma background effect + * Flowing organic color patterns using layered gradients + */ +export class AuroraEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-aurora", + name: "Aurora", + type: "background-animation", + category: "Background Animations", + renderOrder: 10, + }); + } + + defineControls() { + return [ + { + id: "animate-aurora", + type: "checkbox", + label: "Aurora Effect", + defaultValue: false, + }, + { + id: "aurora-speed", + type: "range", + label: "Flow Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "animate-aurora", + description: "Speed of flowing colors", + }, + { + id: "aurora-intensity", + type: "range", + label: "Intensity", + defaultValue: 0.6, + min: 0.2, + max: 1, + step: 0.1, + showWhen: "animate-aurora", + description: "Brightness and opacity", + }, + { + id: "aurora-complexity", + type: "range", + label: "Complexity", + defaultValue: 3, + min: 2, + max: 6, + step: 1, + showWhen: "animate-aurora", + description: "Number of wave layers", + }, + { + id: "aurora-color-scheme", + type: "select", + label: "Color Scheme", + defaultValue: "northern", + options: [ + { value: "northern", label: "Northern Lights" }, + { value: "purple", label: "Purple Dream" }, + { value: "fire", label: "Fire" }, + { value: "ocean", label: "Ocean" }, + { value: "rainbow", label: "Rainbow" }, + ], + showWhen: "animate-aurora", + description: "Color palette", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-aurora"] === true; + } + + getColorScheme(scheme, hue) { + switch (scheme) { + case "northern": + return [ + { h: 120, s: 70, l: 50 }, // Green + { h: 160, s: 70, l: 50 }, // Teal + { h: 200, s: 70, l: 50 }, // Blue + ]; + case "purple": + return [ + { h: 270, s: 70, l: 50 }, // Purple + { h: 300, s: 70, l: 50 }, // Magenta + { h: 330, s: 70, l: 50 }, // Pink + ]; + case "fire": + return [ + { h: 0, s: 80, l: 50 }, // Red + { h: 30, s: 80, l: 50 }, // Orange + { h: 50, s: 80, l: 50 }, // Yellow-Orange + ]; + case "ocean": + return [ + { h: 180, s: 70, l: 50 }, // Cyan + { h: 200, s: 70, l: 50 }, // Light Blue + { h: 220, s: 70, l: 50 }, // Blue + ]; + case "rainbow": + return [ + { h: (hue + 0) % 360, s: 70, l: 50 }, + { h: (hue + 120) % 360, s: 70, l: 50 }, + { h: (hue + 240) % 360, s: 70, l: 50 }, + ]; + default: + return [ + { h: 120, s: 70, l: 50 }, + { h: 180, s: 70, l: 50 }, + { h: 240, s: 70, l: 50 }, + ]; + } + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const speed = controlValues["aurora-speed"] || 1; + const intensity = controlValues["aurora-intensity"] || 0.6; + const complexity = controlValues["aurora-complexity"] || 3; + const colorScheme = controlValues["aurora-color-scheme"] || "northern"; + + const time = animState.getPhase(speed); + + // Create flowing hue shift that loops properly (only used for rainbow scheme) + // Convert phase (0 to 2π) to hue degrees (0 to 360) + const hueShift = (time / (Math.PI * 2)) * 360; + const colors = this.getColorScheme(colorScheme, hueShift); + + // Draw multiple overlapping gradients to create aurora effect + context.globalCompositeOperation = "screen"; // Blend mode for aurora effect + + for (let i = 0; i < complexity; i++) { + const phase = time + i * ((Math.PI * 2) / complexity); + + // Calculate wave positions + const wave1X = + renderData.centerX + Math.sin(phase) * renderData.width * 0.5; + const wave1Y = + renderData.centerY + Math.cos(phase * 1.3) * renderData.height * 0.5; + + // Create radial gradient + const gradient = context.createRadialGradient( + wave1X, + wave1Y, + 0, + wave1X, + wave1Y, + renderData.width * 0.8, + ); + + // Pick color based on wave index + const colorIdx = i % colors.length; + const color = colors[colorIdx]; + + const baseOpacity = intensity * 0.3; + + // Rainbow scheme already has hueShift applied in getColorScheme + // Other schemes use their fixed colors + gradient.addColorStop( + 0, + `hsla(${color.h}, ${color.s}%, ${color.l}%, ${baseOpacity})`, + ); + gradient.addColorStop( + 0.5, + `hsla(${color.h}, ${color.s}%, ${color.l}%, ${baseOpacity * 0.5})`, + ); + gradient.addColorStop( + 1, + `hsla(${color.h}, ${color.s}%, ${color.l}%, 0)`, + ); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } + + // Reset composite operation + context.globalCompositeOperation = "source-over"; + } +} + +export function register(generator) { + generator.registerEffect(new AuroraEffect()); +} diff --git a/static/js/button-generator/effects/background-bubbles.js b/static/js/button-generator/effects/background-bubbles.js new file mode 100644 index 0000000..a72106c --- /dev/null +++ b/static/js/button-generator/effects/background-bubbles.js @@ -0,0 +1,178 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Bubbles rising background effect + * Floating bubbles that rise with drift + */ +export class BubblesEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-bubbles", + name: "Bubbles", + type: "background-animation", + category: "Background Animations", + renderOrder: 10, + }); + + this.bubbles = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-bubbles", + type: "checkbox", + label: "Bubbles Effect", + defaultValue: false, + }, + { + id: "bubble-count", + type: "range", + label: "Bubble Count", + defaultValue: 15, + min: 5, + max: 40, + step: 1, + showWhen: "animate-bubbles", + description: "Number of bubbles", + }, + { + id: "bubble-speed", + type: "range", + label: "Rise Speed", + defaultValue: 1, + min: 0.3, + max: 3, + step: 0.1, + showWhen: "animate-bubbles", + description: "Speed of rising bubbles", + }, + { + id: "bubble-drift", + type: "range", + label: "Drift Amount", + defaultValue: 1, + min: 0, + max: 3, + step: 0.1, + showWhen: "animate-bubbles", + description: "Side-to-side drift", + }, + { + id: "bubble-color", + type: "color", + label: "Bubble Color", + defaultValue: "#4da6ff", + showWhen: "animate-bubbles", + description: "Color of bubbles", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-bubbles"] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const count = controlValues["bubble-count"] || 15; + const speed = controlValues["bubble-speed"] || 1; + const drift = controlValues["bubble-drift"] || 1; + const bubbleColor = controlValues["bubble-color"] || "#4da6ff"; + + // Initialize bubbles on first frame or count change + if (!this.initialized || this.bubbles.length !== count) { + this.bubbles = []; + for (let i = 0; i < count; i++) { + this.bubbles.push({ + x: Math.random() * renderData.width, + startY: Math.random(), // Store as 0-1 ratio instead of pixel value + size: 3 + Math.random() * 8, + speedMultiplier: 0.5 + Math.random() * 1, + driftPhase: Math.random() * Math.PI * 2, + driftSpeed: 0.5 + Math.random() * 1, + }); + } + this.initialized = true; + } + + // Parse color for gradient + const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { r: 77, g: 166, b: 255 }; + }; + + const rgb = hexToRgb(bubbleColor); + + // Draw bubbles + this.bubbles.forEach((bubble) => { + // Calculate Y position based on animation progress for perfect looping + // Each bubble has a different starting offset and speed + const bubbleProgress = + (animState.progress * speed * bubble.speedMultiplier + bubble.startY) % + 1; + + // Convert to pixel position (bubbles rise from bottom to top) + const bubbleY = + renderData.height + bubble.size - bubbleProgress * (renderData.height + bubble.size * 2); + + // Drift side to side + const driftOffset = + Math.sin( + animState.getPhase(bubble.driftSpeed * 0.5) + bubble.driftPhase + ) * + drift * + 2; + const currentX = bubble.x + driftOffset; + + // Draw bubble with gradient for 3D effect + const gradient = context.createRadialGradient( + currentX - bubble.size * 0.3, + bubbleY - bubble.size * 0.3, + 0, + currentX, + bubbleY, + bubble.size + ); + + gradient.addColorStop( + 0, + `rgba(${rgb.r + 40}, ${rgb.g + 40}, ${rgb.b + 40}, 0.6)` + ); + gradient.addColorStop( + 0.6, + `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)` + ); + gradient.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.2)`); + + context.fillStyle = gradient; + context.beginPath(); + context.arc(currentX, bubbleY, bubble.size, 0, Math.PI * 2); + context.fill(); + + // Add highlight + context.fillStyle = "rgba(255, 255, 255, 0.4)"; + context.beginPath(); + context.arc( + currentX - bubble.size * 0.3, + bubbleY - bubble.size * 0.3, + bubble.size * 0.3, + 0, + Math.PI * 2 + ); + context.fill(); + }); + } +} + +export function register(generator) { + generator.registerEffect(new BubblesEffect()); +} diff --git a/static/js/button-generator/effects/background-emoji-wallpaper.js b/static/js/button-generator/effects/background-emoji-wallpaper.js new file mode 100644 index 0000000..e6c38a4 --- /dev/null +++ b/static/js/button-generator/effects/background-emoji-wallpaper.js @@ -0,0 +1,109 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Emoji wallpaper background effect + * Tiles a user-specified emoji across the background + */ +export class EmojiWallpaperEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-emoji-wallpaper', + name: 'Emoji Wallpaper', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'emoji-text', + type: 'text', + label: 'Emoji Character', + defaultValue: '✨', + showWhen: 'bg-type', + description: 'Emoji to tile (can be any text)' + }, + { + id: 'emoji-size', + type: 'range', + label: 'Emoji Size', + defaultValue: 12, + min: 6, + max: 24, + step: 1, + showWhen: 'bg-type', + description: 'Size of each emoji' + }, + { + id: 'emoji-spacing', + type: 'range', + label: 'Emoji Spacing', + defaultValue: 16, + min: 8, + max: 32, + step: 2, + showWhen: 'bg-type', + description: 'Space between emojis' + }, + { + id: 'emoji-bg-color', + type: 'color', + label: 'Background Color', + defaultValue: '#1a1a2e', + showWhen: 'bg-type', + description: 'Background color behind emojis' + }, + { + id: 'emoji-opacity', + type: 'range', + label: 'Emoji Opacity', + defaultValue: 30, + min: 10, + max: 100, + step: 5, + showWhen: 'bg-type', + description: 'Transparency of emojis (lower = more transparent)' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'emoji-wallpaper'; + } + + apply(context, controlValues, animState, renderData) { + const emoji = controlValues['emoji-text'] || '✨'; + const size = controlValues['emoji-size'] || 12; + const spacing = controlValues['emoji-spacing'] || 16; + const bgColor = controlValues['emoji-bg-color'] || '#1a1a2e'; + const opacity = (controlValues['emoji-opacity'] || 30) / 100; + + // Fill background color + context.fillStyle = bgColor; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Setup emoji font + context.font = `${size}px Arial`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.globalAlpha = opacity; + + // Tile emojis + for (let y = 0; y < renderData.height + spacing; y += spacing) { + for (let x = 0; x < renderData.width + spacing; x += spacing) { + // Offset every other row for a brick pattern + const offsetX = (Math.floor(y / spacing) % 2) * (spacing / 2); + context.fillText(emoji, x + offsetX, y); + } + } + + context.globalAlpha = 1.0; + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new EmojiWallpaperEffect()); +} diff --git a/static/js/button-generator/effects/background-external-image.js b/static/js/button-generator/effects/background-external-image.js new file mode 100644 index 0000000..d3ffc9d --- /dev/null +++ b/static/js/button-generator/effects/background-external-image.js @@ -0,0 +1,311 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * External Image Background Effect + * Loads an external image from a URL and displays it as the background + */ +export class ExternalImageBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-external-image', + name: 'External Image Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + + // Cache for loaded images + this.imageCache = new Map(); + this.loadingImages = new Map(); + this.generator = null; + + // Set up event listener for image loads + this.boundImageLoadHandler = this.handleImageLoad.bind(this); + window.addEventListener('imageLoaded', this.boundImageLoadHandler); + } + + /** + * Handle image load event + */ + handleImageLoad() { + // Trigger a redraw if we have a generator reference + if (this.generator) { + this.generator.updatePreview(); + } + } + + defineControls() { + return [ + { + id: 'bg-image-file', + type: 'file', + label: 'Image File', + defaultValue: '', + accept: 'image/*', + showWhen: 'bg-type', + description: 'Select an image file from your computer' + }, + { + id: 'bg-image-fit', + type: 'select', + label: 'Image Fit', + defaultValue: 'cover', + options: [ + { value: 'cover', label: 'Cover (fill, crop if needed)' }, + { value: 'contain', label: 'Contain (fit inside)' }, + { value: 'stretch', label: 'Stretch (may distort)' }, + { value: 'center', label: 'Center (actual size)' }, + { value: 'manual', label: 'Manual (custom zoom & position)' } + ], + showWhen: 'bg-type', + description: 'How the image should fit in the canvas' + }, + { + id: 'bg-image-zoom', + type: 'range', + label: 'Image Zoom', + defaultValue: 100, + min: 10, + max: 500, + step: 5, + showWhen: 'bg-image-fit', + description: 'Zoom level for manual positioning (percentage)' + }, + { + id: 'bg-image-offset-x', + type: 'range', + label: 'Horizontal Position', + defaultValue: 50, + min: 0, + max: 100, + step: 1, + showWhen: 'bg-image-fit', + description: 'Horizontal position of the image (percentage)' + }, + { + id: 'bg-image-offset-y', + type: 'range', + label: 'Vertical Position', + defaultValue: 50, + min: 0, + max: 100, + step: 1, + showWhen: 'bg-image-fit', + description: 'Vertical position of the image (percentage)' + }, + { + id: 'bg-image-opacity', + type: 'range', + label: 'Image Opacity', + defaultValue: 1, + min: 0, + max: 1, + step: 0.05, + showWhen: 'bg-type', + description: 'Transparency of the background image' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'external-image'; + } + + /** + * Start loading an image from a blob URL with caching + * Triggers async loading but returns immediately + * @param {string} blobUrl - Blob URL created from uploaded file + */ + startLoadingImage(blobUrl) { + // Skip if already cached or loading + if (this.imageCache.has(blobUrl) || this.loadingImages.has(blobUrl)) { + return; + } + + // Mark as loading to prevent duplicate requests + this.loadingImages.set(blobUrl, true); + + const img = new Image(); + + img.onload = () => { + this.imageCache.set(blobUrl, img); + this.loadingImages.delete(blobUrl); + // Trigger a redraw when image loads + const event = new CustomEvent('imageLoaded'); + window.dispatchEvent(event); + }; + + img.onerror = () => { + // Cache the error state to prevent retry spam + this.imageCache.set(blobUrl, 'ERROR'); + this.loadingImages.delete(blobUrl); + // Trigger a redraw to show error state + const event = new CustomEvent('imageLoaded'); + window.dispatchEvent(event); + }; + + img.src = blobUrl; + } + + /** + * Get cached image if available + * @param {string} blobUrl - Blob URL + * @returns {HTMLImageElement|string|null} Image element, 'ERROR', or null + */ + getCachedImage(blobUrl) { + return this.imageCache.get(blobUrl) || null; + } + + /** + * Draw image with the specified fit mode + */ + drawImage(context, img, fitMode, opacity, width, height, zoom, offsetX, offsetY) { + context.globalAlpha = opacity; + + const imgRatio = img.width / img.height; + const canvasRatio = width / height; + + let drawX = 0; + let drawY = 0; + let drawWidth = width; + let drawHeight = height; + + switch (fitMode) { + case 'cover': + // Fill the canvas, cropping if necessary + if (imgRatio > canvasRatio) { + // Image is wider, fit height + drawHeight = height; + drawWidth = height * imgRatio; + drawX = (width - drawWidth) / 2; + } else { + // Image is taller, fit width + drawWidth = width; + drawHeight = width / imgRatio; + drawY = (height - drawHeight) / 2; + } + break; + + case 'contain': + // Fit inside the canvas, showing all of the image + if (imgRatio > canvasRatio) { + // Image is wider, fit width + drawWidth = width; + drawHeight = width / imgRatio; + drawY = (height - drawHeight) / 2; + } else { + // Image is taller, fit height + drawHeight = height; + drawWidth = height * imgRatio; + drawX = (width - drawWidth) / 2; + } + break; + + case 'center': + // Center the image at actual size + drawWidth = img.width; + drawHeight = img.height; + drawX = (width - drawWidth) / 2; + drawY = (height - drawHeight) / 2; + break; + + case 'manual': + // Manual positioning with zoom and offset controls + const zoomFactor = zoom / 100; + + // Start with the larger dimension to ensure coverage + if (imgRatio > canvasRatio) { + // Image is wider relative to canvas + drawHeight = height * zoomFactor; + drawWidth = drawHeight * imgRatio; + } else { + // Image is taller relative to canvas + drawWidth = width * zoomFactor; + drawHeight = drawWidth / imgRatio; + } + + // Calculate the range of movement (how far can we move the image) + const maxOffsetX = drawWidth - width; + const maxOffsetY = drawHeight - height; + + // Convert percentage (0-100) to actual position + // 0% = image fully left/top, 100% = image fully right/bottom + drawX = -(maxOffsetX * offsetX / 100); + drawY = -(maxOffsetY * offsetY / 100); + break; + + case 'stretch': + // Stretch to fill (default values already set) + break; + } + + context.drawImage(img, drawX, drawY, drawWidth, drawHeight); + context.globalAlpha = 1; // Reset alpha + } + + apply(context, controlValues, animState, renderData) { + const file = controlValues['bg-image-file']; + const fitMode = controlValues['bg-image-fit'] || 'cover'; + const opacity = controlValues['bg-image-opacity'] ?? 1; + const zoom = controlValues['bg-image-zoom'] ?? 100; + const offsetX = controlValues['bg-image-offset-x'] ?? 50; + const offsetY = controlValues['bg-image-offset-y'] ?? 50; + + // If no file selected, fill with a placeholder color + if (!file || !file.blobUrl) { + context.fillStyle = '#cccccc'; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Draw placeholder text + context.fillStyle = '#666666'; + context.font = '8px Arial'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Select an image', renderData.centerX, renderData.centerY); + return; + } + + // Start loading if not already cached or loading + const cachedImage = this.getCachedImage(file.blobUrl); + + if (!cachedImage) { + // Not cached yet - start loading + this.startLoadingImage(file.blobUrl); + + // Draw loading state + context.fillStyle = '#3498db'; + context.fillRect(0, 0, renderData.width, renderData.height); + + context.fillStyle = '#ffffff'; + context.font = '6px Arial'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Loading...', renderData.centerX, renderData.centerY); + return; + } + + if (cachedImage === 'ERROR') { + // Failed to load + context.fillStyle = '#ff6b6b'; + context.fillRect(0, 0, renderData.width, renderData.height); + + context.fillStyle = '#ffffff'; + context.font = '6px Arial'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Failed to load', renderData.centerX, renderData.centerY - 4); + context.fillText('image', renderData.centerX, renderData.centerY + 4); + return; + } + + // Image is loaded - draw it + this.drawImage(context, cachedImage, fitMode, opacity, renderData.width, renderData.height, zoom, offsetX, offsetY); + } +} + +// Auto-register effect +export function register(generator) { + const effect = new ExternalImageBackgroundEffect(); + effect.generator = generator; // Store reference for redraws + generator.registerEffect(effect); +} diff --git a/static/js/button-generator/effects/background-fire.js b/static/js/button-generator/effects/background-fire.js new file mode 100644 index 0000000..0e3fb2c --- /dev/null +++ b/static/js/button-generator/effects/background-fire.js @@ -0,0 +1,216 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Fire background effect + * Animated flames rising from bottom using particles + */ +export class FireEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-fire", + name: "Fire", + type: "background-animation", + category: "Background Animations", + renderOrder: 10, + }); + + this.particles = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-fire", + type: "checkbox", + label: "Fire Effect", + defaultValue: false, + }, + { + id: "fire-intensity", + type: "range", + label: "Intensity", + defaultValue: 50, + min: 20, + max: 100, + step: 5, + showWhen: "animate-fire", + description: "Number of flame particles", + }, + { + id: "fire-height", + type: "range", + label: "Flame Height", + defaultValue: 0.6, + min: 0.3, + max: 1, + step: 0.1, + showWhen: "animate-fire", + description: "How high flames reach", + }, + { + id: "fire-speed", + type: "range", + label: "Speed", + defaultValue: 1, + min: 0.3, + max: 3, + step: 0.1, + showWhen: "animate-fire", + description: "Speed of rising flames", + }, + { + id: "fire-color-scheme", + type: "select", + label: "Color Scheme", + defaultValue: "normal", + options: [ + { value: "normal", label: "Normal Fire" }, + { value: "blue", label: "Blue Flame" }, + { value: "green", label: "Green Flame" }, + { value: "purple", label: "Purple Flame" }, + { value: "white", label: "White Hot" }, + ], + showWhen: "animate-fire", + description: "Flame color", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-fire"] === true; + } + + getFireColors(scheme) { + switch (scheme) { + case "normal": + return [ + { r: 255, g: 60, b: 0 }, // Red-orange + { r: 255, g: 140, b: 0 }, // Orange + { r: 255, g: 200, b: 0 }, // Yellow + ]; + case "blue": + return [ + { r: 0, g: 100, b: 255 }, // Blue + { r: 100, g: 180, b: 255 }, // Light blue + { r: 200, g: 230, b: 255 }, // Very light blue + ]; + case "green": + return [ + { r: 0, g: 200, b: 50 }, // Green + { r: 100, g: 255, b: 100 }, // Light green + { r: 200, g: 255, b: 150 }, // Very light green + ]; + case "purple": + return [ + { r: 150, g: 0, b: 255 }, // Purple + { r: 200, g: 100, b: 255 }, // Light purple + { r: 230, g: 180, b: 255 }, // Very light purple + ]; + case "white": + return [ + { r: 255, g: 200, b: 150 }, // Warm white + { r: 255, g: 240, b: 200 }, // Light white + { r: 255, g: 255, b: 255 }, // Pure white + ]; + default: + return [ + { r: 255, g: 60, b: 0 }, + { r: 255, g: 140, b: 0 }, + { r: 255, g: 200, b: 0 }, + ]; + } + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues["fire-intensity"] || 50; + const height = controlValues["fire-height"] || 0.6; + const speed = controlValues["fire-speed"] || 1; + const colorScheme = controlValues["fire-color-scheme"] || "normal"; + + const colors = this.getFireColors(colorScheme); + const maxHeight = renderData.height * height; + + // Spawn new particles at the bottom + for (let i = 0; i < intensity / 10; i++) { + this.particles.push({ + x: Math.random() * renderData.width, + y: renderData.height, + vx: (Math.random() - 0.5) * 1.5, + vy: -(2 + Math.random() * 3) * speed, + size: 2 + Math.random() * 6, + life: 1.0, + colorIndex: Math.random(), + }); + } + + // Update and draw particles + this.particles = this.particles.filter((particle) => { + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Add some turbulence + particle.vx += (Math.random() - 0.5) * 0.2; + particle.vy *= 0.98; // Slow down as they rise + + // Fade out based on height and time + const heightRatio = + (renderData.height - particle.y) / renderData.height; + particle.life -= 0.015; + + if (particle.life > 0 && particle.y > renderData.height - maxHeight) { + // Choose color based on life (hotter at bottom, cooler at top) + const colorProgress = 1 - particle.life; + const colorIdx = Math.floor(colorProgress * (colors.length - 1)); + const colorBlend = (colorProgress * (colors.length - 1)) % 1; + + const c1 = colors[Math.min(colorIdx, colors.length - 1)]; + const c2 = colors[Math.min(colorIdx + 1, colors.length - 1)]; + + const r = Math.floor(c1.r + (c2.r - c1.r) * colorBlend); + const g = Math.floor(c1.g + (c2.g - c1.g) * colorBlend); + const b = Math.floor(c1.b + (c2.b - c1.b) * colorBlend); + + // Draw particle with gradient + const gradient = context.createRadialGradient( + particle.x, + particle.y, + 0, + particle.x, + particle.y, + particle.size + ); + + gradient.addColorStop( + 0, + `rgba(${r}, ${g}, ${b}, ${particle.life * 0.8})` + ); + gradient.addColorStop( + 0.5, + `rgba(${r}, ${g}, ${b}, ${particle.life * 0.5})` + ); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); + + context.fillStyle = gradient; + context.beginPath(); + context.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + context.fill(); + + return true; + } + return false; + }); + + // Limit particle count + if (this.particles.length > intensity * 5) { + this.particles = this.particles.slice(-intensity * 5); + } + } +} + +export function register(generator) { + generator.registerEffect(new FireEffect()); +} diff --git a/static/js/button-generator/effects/background-gradient.js b/static/js/button-generator/effects/background-gradient.js new file mode 100644 index 0000000..4e8c8c8 --- /dev/null +++ b/static/js/button-generator/effects/background-gradient.js @@ -0,0 +1,76 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Gradient background effect + */ +export class GradientBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-gradient', + name: 'Gradient Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'gradient-color1', + type: 'color', + label: 'Gradient Color 1', + defaultValue: '#ff0000', + showWhen: 'bg-type', + description: 'Start color of gradient' + }, + { + id: 'gradient-color2', + type: 'color', + label: 'Gradient Color 2', + defaultValue: '#0000ff', + showWhen: 'bg-type', + description: 'End color of gradient' + }, + { + id: 'gradient-angle', + type: 'range', + label: 'Gradient Angle', + defaultValue: 90, + min: 0, + max: 360, + step: 1, + showWhen: 'bg-type', + description: 'Angle of gradient direction' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'gradient'; + } + + apply(context, controlValues, animState, renderData) { + const color1 = controlValues['gradient-color1'] || '#ff0000'; + const color2 = controlValues['gradient-color2'] || '#0000ff'; + const angle = (controlValues['gradient-angle'] || 90) * (Math.PI / 180); + + // Calculate gradient endpoints + const x1 = renderData.centerX + Math.cos(angle) * renderData.centerX; + const y1 = renderData.centerY + Math.sin(angle) * renderData.centerY; + const x2 = renderData.centerX - Math.cos(angle) * renderData.centerX; + const y2 = renderData.centerY - Math.sin(angle) * renderData.centerY; + + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new GradientBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/background-rain.js b/static/js/button-generator/effects/background-rain.js new file mode 100644 index 0000000..eb3b928 --- /dev/null +++ b/static/js/button-generator/effects/background-rain.js @@ -0,0 +1,139 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Raining background effect + * Animated raindrops falling down the button + */ +export class RainBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-rain', + name: 'Rain Effect', + type: 'background-animation', + category: 'Background Animations', + renderOrder: 10 + }); + + // Initialize raindrop positions (persistent across frames) + this.raindrops = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: 'animate-rain', + type: 'checkbox', + label: 'Rain Effect', + defaultValue: false + }, + { + id: 'rain-density', + type: 'range', + label: 'Rain Density', + defaultValue: 15, + min: 5, + max: 30, + step: 1, + showWhen: 'animate-rain', + description: 'Number of raindrops' + }, + { + id: 'rain-speed', + type: 'range', + label: 'Rain Speed', + defaultValue: 2, + min: 1, + max: 3, + step: 1, + showWhen: 'animate-rain', + description: 'Speed of falling rain' + }, + { + id: 'rain-color', + type: 'color', + label: 'Rain Color', + defaultValue: '#6ba3ff', + showWhen: 'animate-rain', + description: 'Color of raindrops' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-rain'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const density = controlValues['rain-density'] || 15; + const speed = controlValues['rain-speed'] || 1.5; + const color = controlValues['rain-color'] || '#6ba3ff'; + + // Initialize raindrop base properties (seed values for deterministic animation) + if (!this.initialized || this.raindrops.length !== density) { + this.raindrops = []; + for (let i = 0; i < density; i++) { + // Use multiple large prime seeds for better distribution + const seed1 = i * 2654435761; // Large prime multiplier + const seed2 = i * 2246822519 + 3141592653; + const seed3 = i * 3266489917 + 1618033988; + const seed4 = i * 374761393 + 2718281828; + + // Hash-like function for better pseudo-random distribution + const hash = (s) => { + const x = Math.sin(s * 0.0001) * 10000; + return x - Math.floor(x); + }; + + this.raindrops.push({ + xOffset: hash(seed1), // 0 to 1 + startY: hash(seed2), // 0 to 1 (initial Y position within loop) + length: 2 + hash(seed3) * 4, + speedMultiplier: 0.8 + hash(seed4) * 0.4 + }); + } + this.initialized = true; + } + + // Draw raindrops + context.strokeStyle = color; + context.lineWidth = 1; + context.lineCap = 'round'; + + this.raindrops.forEach(drop => { + // Calculate position based on current frame for perfect looping + const totalDistance = renderData.height + drop.length * 2; + + // Progress through the animation (0 to 1) + const progress = animState.progress; // 0 at frame 0, ~0.975 at frame 39 + + // All drops complete the same number of cycles (based on speed) + // startY provides the offset for varied starting positions + // Round speed to nearest 0.5 to ensure clean cycles + const cycles = Math.round(speed * 2) / 2; // e.g., 1.5 -> 1.5, 1.7 -> 1.5, 2.3 -> 2.5 + const cycleProgress = (progress * cycles + drop.startY) % 1.0; + const y = cycleProgress * totalDistance - drop.length; + + // X position remains constant throughout the loop + const x = drop.xOffset * renderData.width; + + // Vary opacity slightly for depth effect (using speedMultiplier for variation) + const opacity = 0.4 + drop.speedMultiplier * 0.3; // 0.64 to 0.76 + + // Draw raindrop + context.globalAlpha = opacity; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x, y + drop.length); + context.stroke(); + context.globalAlpha = 1.0; + }); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RainBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/background-rainbow.js b/static/js/button-generator/effects/background-rainbow.js new file mode 100644 index 0000000..07dae99 --- /dev/null +++ b/static/js/button-generator/effects/background-rainbow.js @@ -0,0 +1,137 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Rainbow flash background effect + * Animates background through rainbow colors + */ +export class RainbowBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-rainbow', + name: 'Rainbow Background', + type: 'background', + category: 'Background Animations', + renderOrder: 2 // After base background + }); + } + + defineControls() { + return [ + { + id: 'animate-bg-rainbow', + type: 'checkbox', + label: 'Rainbow Flash', + defaultValue: false + }, + { + id: 'rainbow-speed', + type: 'range', + label: 'Rainbow Speed', + defaultValue: 1, + min: 0.1, + max: 5, + step: 0.1, + showWhen: 'animate-bg-rainbow', + description: 'Speed of rainbow cycling' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-bg-rainbow'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const speed = controlValues['rainbow-speed'] || 1; + const hue = (animState.progress * speed * 360) % 360; + + const bgType = controlValues['bg-type']; + + if (bgType === 'solid') { + // Solid rainbow + context.fillStyle = `hsl(${hue}, 70%, 50%)`; + context.fillRect(0, 0, renderData.width, renderData.height); + } else if (bgType === 'gradient') { + // Rainbow gradient + const angle = (controlValues['gradient-angle'] || 90) * (Math.PI / 180); + const x1 = renderData.centerX + Math.cos(angle) * renderData.centerX; + const y1 = renderData.centerY + Math.sin(angle) * renderData.centerY; + const x2 = renderData.centerX - Math.cos(angle) * renderData.centerX; + const y2 = renderData.centerY - Math.sin(angle) * renderData.centerY; + + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, `hsl(${hue}, 70%, 50%)`); + gradient.addColorStop(1, `hsl(${(hue + 60) % 360}, 70%, 60%)`); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } + } +} + +/** + * Rainbow gradient sweep effect + * Creates a moving rainbow gradient that sweeps across the button + */ +export class RainbowGradientSweepEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-rainbow-gradient', + name: 'Rainbow Gradient Sweep', + type: 'general', + category: 'Background Animations', + renderOrder: 50 // After background and text + }); + } + + defineControls() { + return [ + { + id: 'animate-bg-rainbow-gradient', + type: 'checkbox', + label: 'Rainbow Sweep', + defaultValue: false, + description: 'Moving rainbow gradient overlay' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-bg-rainbow-gradient'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + // Map progress to position (-100 to 100) + const position = animState.progress * 200 - 100; + + // Create a horizontal gradient that sweeps across + const gradient = context.createLinearGradient( + position - 50, + 0, + position + 50, + 0 + ); + + // Create rainbow stops that also cycle through colors + const hueOffset = animState.progress * 360; + gradient.addColorStop(0, `hsla(${(hueOffset + 0) % 360}, 80%, 50%, 0)`); + gradient.addColorStop(0.2, `hsla(${(hueOffset + 60) % 360}, 80%, 50%, 0.6)`); + gradient.addColorStop(0.4, `hsla(${(hueOffset + 120) % 360}, 80%, 50%, 0.8)`); + gradient.addColorStop(0.6, `hsla(${(hueOffset + 180) % 360}, 80%, 50%, 0.8)`); + gradient.addColorStop(0.8, `hsla(${(hueOffset + 240) % 360}, 80%, 50%, 0.6)`); + gradient.addColorStop(1, `hsla(${(hueOffset + 300) % 360}, 80%, 50%, 0)`); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effects +export function register(generator) { + generator.registerEffect(new RainbowBackgroundEffect()); + generator.registerEffect(new RainbowGradientSweepEffect()); +} diff --git a/static/js/button-generator/effects/background-solid.js b/static/js/button-generator/effects/background-solid.js new file mode 100644 index 0000000..a835100 --- /dev/null +++ b/static/js/button-generator/effects/background-solid.js @@ -0,0 +1,57 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Solid color background effect + */ +export class SolidBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-solid", + name: "Solid Background", + type: "background", + category: "Background", + renderOrder: 1, + }); + } + + defineControls() { + return [ + { + id: "bg-type", + type: "select", + label: "Background Type", + defaultValue: "solid", + options: [ + { value: "solid", label: "Solid Color" }, + { value: "gradient", label: "Gradient" }, + { value: "texture", label: "Texture" }, + { value: "emoji-wallpaper", label: "Emoji Wallpaper" }, + { value: 'external-image', label: 'Image Upload' } + ], + }, + { + id: "bg-color", + type: "color", + label: "Background Color", + defaultValue: "#4a90e2", + showWhen: "bg-type", + description: "Solid background color", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["bg-type"] === "solid"; + } + + apply(context, controlValues, animState, renderData) { + const color = controlValues["bg-color"] || "#4a90e2"; + context.fillStyle = color; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new SolidBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/background-starfield.js b/static/js/button-generator/effects/background-starfield.js new file mode 100644 index 0000000..61ed860 --- /dev/null +++ b/static/js/button-generator/effects/background-starfield.js @@ -0,0 +1,187 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Starfield background effect + * Twinkling stars with optional shooting stars + */ +export class StarfieldEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-starfield", + name: "Starfield", + type: "background-animation", + category: "Background Animations", + renderOrder: 10, + }); + + this.stars = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-starfield", + type: "checkbox", + label: "Starfield Effect", + defaultValue: false, + }, + { + id: "star-density", + type: "range", + label: "Star Density", + defaultValue: 30, + min: 10, + max: 80, + step: 5, + showWhen: "animate-starfield", + description: "Number of stars", + }, + { + id: "star-twinkle-speed", + type: "range", + label: "Twinkle Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 0.1, + showWhen: "animate-starfield", + description: "Speed of twinkling", + }, + { + id: "star-shooting-enabled", + type: "checkbox", + label: "Shooting Stars", + defaultValue: true, + showWhen: "animate-starfield", + description: "Enable shooting stars", + }, + { + id: "star-color", + type: "color", + label: "Star Color", + defaultValue: "#ffffff", + showWhen: "animate-starfield", + description: "Color of stars", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-starfield"] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const density = controlValues["star-density"] || 30; + const twinkleSpeed = controlValues["star-twinkle-speed"] || 1; + const shootingEnabled = controlValues["star-shooting-enabled"] !== false; + const starColor = controlValues["star-color"] || "#ffffff"; + + // Initialize stars with deterministic positions (seed-based) + if (!this.initialized || this.stars.length !== density) { + this.stars = []; + for (let i = 0; i < density; i++) { + // Use density as part of the seed so pattern changes with density + // Use multiple large prime seeds for better distribution + const densitySeed = density * 97531; // Density affects all stars + const seed1 = (i * 2654435761) + densitySeed; + const seed2 = (i * 2246822519) + 3141592653 + densitySeed; + const seed3 = (i * 3266489917) + 1618033988 + densitySeed; + const seed4 = (i * 374761393) + 2718281828 + densitySeed; + const seed5 = (i * 1103515245) + 12345 + densitySeed; + + // Hash-like function for better pseudo-random distribution + const hash = (s) => { + const x = Math.sin(s * 0.0001) * 10000; + return x - Math.floor(x); + }; + + this.stars.push({ + x: hash(seed1) * renderData.width, + y: hash(seed2) * renderData.height, + size: 0.5 + hash(seed3) * 1.5, + twinkleOffset: hash(seed4) * Math.PI * 2, + twinkleSpeed: 0.5 + hash(seed5) * 1.5, + }); + } + this.initialized = true; + } + + // Draw twinkling stars + this.stars.forEach((star) => { + const twinkle = + Math.sin( + animState.getPhase(twinkleSpeed * star.twinkleSpeed) + + star.twinkleOffset, + ) * + 0.5 + + 0.5; + const opacity = 0.3 + twinkle * 0.7; + + context.fillStyle = starColor; + context.globalAlpha = opacity; + context.beginPath(); + context.arc(star.x, star.y, star.size, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 1.0; + }); + + // Shooting stars - deterministic based on frame number + if (shootingEnabled) { + // Define shooting star spawn schedule (deterministic) + const shootingStarSpawns = [ + { startFrame: 5, seed: 12345 }, + { startFrame: 18, seed: 67890 }, + { startFrame: 31, seed: 24680 }, + ]; + + shootingStarSpawns.forEach((spawn) => { + const framesSinceSpawn = animState.frame - spawn.startFrame; + const duration = 25; // Frames the shooting star is visible + + if (framesSinceSpawn >= 0 && framesSinceSpawn < duration) { + // Calculate deterministic properties from seed + const hash = (s) => { + const x = Math.sin(s * 0.0001) * 10000; + return x - Math.floor(x); + }; + + const startX = hash(spawn.seed * 1) * renderData.width; + const startY = -10; + const vx = (hash(spawn.seed * 2) - 0.5) * 2; + const vy = 3 + hash(spawn.seed * 3) * 2; + + // Calculate position based on frames elapsed + const x = startX + vx * framesSinceSpawn; + const y = startY + vy * framesSinceSpawn; + const life = 1.0 - framesSinceSpawn / duration; + + if (life > 0) { + // Draw shooting star trail + const gradient = context.createLinearGradient( + x, + y, + x - vx * 5, + y - vy * 5, + ); + gradient.addColorStop(0, `rgba(255, 255, 255, ${life * 0.8})`); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + + context.strokeStyle = gradient; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x - vx * 5, y - vy * 5); + context.stroke(); + } + } + }); + } + } +} + +export function register(generator) { + generator.registerEffect(new StarfieldEffect()); +} diff --git a/static/js/button-generator/effects/background-texture.js b/static/js/button-generator/effects/background-texture.js new file mode 100644 index 0000000..8afccbd --- /dev/null +++ b/static/js/button-generator/effects/background-texture.js @@ -0,0 +1,217 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Texture background effect + * Provides various procedural texture patterns + */ +export class TextureBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-texture', + name: 'Texture Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'texture-type', + type: 'select', + label: 'Texture Type', + defaultValue: 'dots', + showWhen: 'bg-type', + options: [ + { value: 'dots', label: 'Dots' }, + { value: 'grid', label: 'Grid' }, + { value: 'diagonal', label: 'Diagonal Lines' }, + { value: 'checkerboard', label: 'Checkerboard' }, + { value: 'noise', label: 'Noise' }, + { value: 'stars', label: 'Stars' } + ] + }, + { + id: 'texture-color1', + type: 'color', + label: 'Texture Color 1', + defaultValue: '#000000', + showWhen: 'bg-type', + description: 'Base color' + }, + { + id: 'texture-color2', + type: 'color', + label: 'Texture Color 2', + defaultValue: '#ffffff', + showWhen: 'bg-type', + description: 'Pattern color' + }, + { + id: 'texture-scale', + type: 'range', + label: 'Texture Scale', + defaultValue: 50, + min: 10, + max: 100, + step: 5, + showWhen: 'bg-type', + description: 'Size/density of pattern' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'texture'; + } + + apply(context, controlValues, animState, renderData) { + const type = controlValues['texture-type'] || 'dots'; + const color1 = controlValues['texture-color1'] || '#000000'; + const color2 = controlValues['texture-color2'] || '#ffffff'; + const scale = controlValues['texture-scale'] || 50; + + const texture = this.drawTexture( + type, + color1, + color2, + scale, + renderData.width, + renderData.height + ); + + context.drawImage(texture, 0, 0); + } + + /** + * Draw texture pattern to a temporary canvas + */ + drawTexture(type, color1, color2, scale, width, height) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + const ctx = tempCanvas.getContext('2d'); + + // Fill base color + ctx.fillStyle = color1; + ctx.fillRect(0, 0, width, height); + + // Draw pattern + ctx.fillStyle = color2; + const size = Math.max(2, Math.floor(scale / 10)); + + switch (type) { + case 'dots': + this.drawDots(ctx, width, height, size); + break; + case 'grid': + this.drawGrid(ctx, width, height, size); + break; + case 'diagonal': + this.drawDiagonal(ctx, width, height, size); + break; + case 'checkerboard': + this.drawCheckerboard(ctx, width, height, size); + break; + case 'noise': + this.drawNoise(ctx, width, height); + break; + case 'stars': + this.drawStars(ctx, width, height, scale); + break; + } + + return tempCanvas; + } + + /** + * Draw dots pattern + */ + drawDots(ctx, width, height, size) { + for (let y = 0; y < height; y += size * 2) { + for (let x = 0; x < width; x += size * 2) { + ctx.beginPath(); + ctx.arc(x, y, size / 2, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + /** + * Draw grid pattern + */ + drawGrid(ctx, width, height, size) { + // Vertical lines + for (let x = 0; x < width; x += size) { + ctx.fillRect(x, 0, 1, height); + } + // Horizontal lines + for (let y = 0; y < height; y += size) { + ctx.fillRect(0, y, width, 1); + } + } + + /** + * Draw diagonal lines pattern + */ + drawDiagonal(ctx, width, height, size) { + for (let i = -height; i < width; i += size) { + ctx.fillRect(i, 0, 2, height); + ctx.save(); + ctx.translate(i + 1, 0); + ctx.rotate(Math.PI / 4); + ctx.fillRect(0, 0, 2, Math.max(width, height)); + ctx.restore(); + } + } + + /** + * Draw checkerboard pattern + */ + drawCheckerboard(ctx, width, height, size) { + for (let y = 0; y < height; y += size) { + for (let x = 0; x < width; x += size) { + if ((x / size + y / size) % 2 === 0) { + ctx.fillRect(x, y, size, size); + } + } + } + } + + /** + * Draw random noise pattern + */ + drawNoise(ctx, width, height) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (Math.random() > 0.5) { + ctx.fillRect(x, y, 1, 1); + } + } + } + } + + /** + * Draw stars pattern + */ + drawStars(ctx, width, height, scale) { + const numStars = scale; + for (let i = 0; i < numStars; i++) { + const x = Math.floor(Math.random() * width); + const y = Math.floor(Math.random() * height); + + // Draw plus-shape star + ctx.fillRect(x, y, 1, 1); // Center + ctx.fillRect(x - 1, y, 1, 1); // Left + ctx.fillRect(x + 1, y, 1, 1); // Right + ctx.fillRect(x, y - 1, 1, 1); // Top + ctx.fillRect(x, y + 1, 1, 1); // Bottom + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new TextureBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/border.js b/static/js/button-generator/effects/border.js new file mode 100644 index 0000000..0ed6029 --- /dev/null +++ b/static/js/button-generator/effects/border.js @@ -0,0 +1,401 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Border effect + * Draws borders around the button with various styles + */ +export class BorderEffect extends ButtonEffect { + constructor() { + super({ + id: "border", + name: "Border", + type: "border", + category: "Border", + renderOrder: 10, + }); + } + + defineControls() { + return [ + { + id: "border-width", + type: "range", + label: "Border Width", + defaultValue: 2, + min: 0, + max: 5, + step: 1, + description: "Width of border in pixels", + }, + { + id: "border-color", + type: "color", + label: "Border Color", + defaultValue: "#000000", + }, + { + id: "border-style", + type: "select", + label: "Border Style", + defaultValue: "solid", + options: [ + { value: "solid", label: "Solid" }, + { value: "dashed", label: "Dashed" }, + { value: "dotted", label: "Dotted" }, + { value: "double", label: "Double" }, + { value: "inset", label: "Inset (3D)" }, + { value: "outset", label: "Outset (3D)" }, + { value: "ridge", label: "Ridge" }, + { value: "rainbow", label: "Rainbow (Animated)" }, + { value: "marching-ants", label: "Marching Ants" }, + { value: "checkerboard", label: "Checkerboard" }, + ], + }, + { + id: "border-rainbow-speed", + type: "range", + label: "Rainbow Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "border-style", + description: "Speed of rainbow animation", + }, + { + id: "border-march-speed", + type: "range", + label: "March Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "border-style", + description: "Speed of marching animation", + }, + ]; + } + + isEnabled(controlValues) { + const width = controlValues["border-width"] || 0; + return width > 0; + } + + apply(context, controlValues, animState, renderData) { + const width = controlValues["border-width"] || 0; + if (width === 0) return; + + const color = controlValues["border-color"] || "#000000"; + const style = controlValues["border-style"] || "solid"; + + if (style === "solid") { + this.drawSolidBorder(context, width, color, renderData); + } else if (style === "dashed") { + this.drawDashedBorder(context, width, color, renderData); + } else if (style === "dotted") { + this.drawDottedBorder(context, width, color, renderData); + } else if (style === "double") { + this.drawDoubleBorder(context, width, color, renderData); + } else if (style === "inset" || style === "outset") { + this.draw3DBorder(context, width, color, style === "outset", renderData); + } else if (style === "ridge") { + this.drawRidgeBorder(context, width, renderData); + } else if (style === "rainbow") { + const speed = controlValues["border-rainbow-speed"] || 1; + this.drawRainbowBorder(context, width, animState, speed, renderData); + } else if (style === "marching-ants") { + const speed = controlValues["border-march-speed"] || 1; + this.drawMarchingAntsBorder(context, width, animState, speed, renderData); + } else if (style === "checkerboard") { + this.drawCheckerboardBorder(context, width, color, renderData); + } + } + + /** + * Draw solid border + */ + drawSolidBorder(context, width, color, renderData) { + context.strokeStyle = color; + context.lineWidth = width; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width, + ); + } + + /** + * Draw 3D inset/outset border + */ + draw3DBorder(context, width, color, isOutset, renderData) { + const w = renderData.width; + const h = renderData.height; + const t = width; + + const normalized = color.toLowerCase(); + const isPureBlack = normalized === "#000000"; + const isPureWhite = normalized === "#ffffff"; + + let lightColor; + let darkColor; + + if (isPureBlack || isPureWhite) { + lightColor = isOutset ? "#ffffff" : "#000000"; + darkColor = isOutset ? "#000000" : "#ffffff"; + } else { + const lighter = this.adjustColor(color, 0.25); + const darker = this.adjustColor(color, -0.25); + + lightColor = isOutset ? lighter : darker; + darkColor = isOutset ? darker : lighter; + } + + context.fillStyle = lightColor; + context.fillRect(0, 0, w - t, t); + context.fillRect(0, t, t, h - t); + + context.fillStyle = darkColor; + context.fillRect(t, h - t, w - t, t); + context.fillRect(w - t, 0, t, h - t); + + this.drawBevelCorners(context, t, w, h, lightColor, darkColor, isOutset); + } + + drawBevelCorners(ctx, t, w, h, light, dark, isOutset) { + // Top-left corner + ctx.fillStyle = dark; + ctx.beginPath(); + ctx.moveTo(0, h); + ctx.lineTo(t, h); + ctx.lineTo(t, h - t); + ctx.closePath(); + ctx.fill(); + + // Bottom-right corner + ctx.fillStyle = light; + ctx.beginPath(); + ctx.moveTo(w - t, 0); + ctx.lineTo(w - t, t); + ctx.lineTo(w, 0); + ctx.closePath(); + ctx.fill(); + } + + /** + * Draw ridge border (double 3D effect) + */ + drawRidgeBorder(context, width, renderData) { + // Outer ridge (light) + context.strokeStyle = "#ffffff"; + context.lineWidth = width / 2; + context.strokeRect( + width / 4, + width / 4, + renderData.width - width / 2, + renderData.height - width / 2, + ); + + // Inner ridge (dark) + context.strokeStyle = "#000000"; + context.strokeRect( + (width * 3) / 4, + (width * 3) / 4, + renderData.width - width * 1.5, + renderData.height - width * 1.5, + ); + } + + adjustColor(hex, amount) { + // hex: "#rrggbb", amount: -1.0 .. 1.0 + let r = parseInt(hex.slice(1, 3), 16); + let g = parseInt(hex.slice(3, 5), 16); + let b = parseInt(hex.slice(5, 7), 16); + + const adjust = (c) => + Math.min(255, Math.max(0, Math.round(c + amount * 255))); + + r = adjust(r); + g = adjust(g); + b = adjust(b); + + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + } + + /** + * Draw dashed border + */ + drawDashedBorder(context, width, color, renderData) { + context.strokeStyle = color; + context.lineWidth = width; + context.setLineDash([6, 3]); // 6px dash, 3px gap + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + context.setLineDash([]); // Reset to solid + } + + /** + * Draw dotted border + */ + drawDottedBorder(context, width, color, renderData) { + context.strokeStyle = color; + context.lineWidth = width; + context.setLineDash([2, 3]); // 2px dot, 3px gap + context.lineCap = "round"; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + context.setLineDash([]); // Reset to solid + context.lineCap = "butt"; + } + + /** + * Draw double border + */ + drawDoubleBorder(context, width, color, renderData) { + const gap = Math.max(1, Math.floor(width / 3)); + const lineWidth = Math.max(1, Math.floor((width - gap) / 2)); + + context.strokeStyle = color; + context.lineWidth = lineWidth; + + // Outer border + context.strokeRect( + lineWidth / 2, + lineWidth / 2, + renderData.width - lineWidth, + renderData.height - lineWidth + ); + + // Inner border + const innerOffset = lineWidth + gap; + context.strokeRect( + innerOffset + lineWidth / 2, + innerOffset + lineWidth / 2, + renderData.width - innerOffset * 2 - lineWidth, + renderData.height - innerOffset * 2 - lineWidth + ); + } + + /** + * Draw rainbow animated border + */ + drawRainbowBorder(context, width, animState, speed, renderData) { + if (!animState) { + // Fallback to solid if no animation + this.drawSolidBorder(context, width, "#ff0000", renderData); + return; + } + + const hue = (animState.progress * speed * 360) % 360; + const color = `hsl(${hue}, 80%, 50%)`; + + context.strokeStyle = color; + context.lineWidth = width; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + } + + /** + * Draw marching ants animated border + */ + drawMarchingAntsBorder(context, width, animState, speed, renderData) { + if (!animState) { + // Fallback to dashed if no animation + this.drawDashedBorder(context, width, "#000000", renderData); + return; + } + + // Animate the dash offset using phase for smooth looping + const phase = animState.getPhase(speed); + const dashLength = 9; // 4px dash + 5px gap = 9px total + const offset = (phase / (Math.PI * 2)) * dashLength; + + context.strokeStyle = "#000000"; + context.lineWidth = width; + context.setLineDash([4, 5]); + context.lineDashOffset = -offset; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + context.setLineDash([]); + context.lineDashOffset = 0; + } + + /** + * Draw checkerboard border + */ + drawCheckerboardBorder(context, width, color, renderData) { + const squareSize = Math.max(2, width); + const w = renderData.width; + const h = renderData.height; + + // Parse the color + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + + // Create light and dark versions + const darkColor = color; + const lightColor = `rgb(${Math.min(255, r + 60)}, ${Math.min( + 255, + g + 60 + )}, ${Math.min(255, b + 60)})`; + + // Draw checkerboard on all four sides + // Top + for (let x = 0; x < w; x += squareSize) { + for (let y = 0; y < width; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + + // Bottom + for (let x = 0; x < w; x += squareSize) { + for (let y = h - width; y < h; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + + // Left + for (let x = 0; x < width; x += squareSize) { + for (let y = width; y < h - width; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + + // Right + for (let x = w - width; x < w; x += squareSize) { + for (let y = width; y < h - width; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new BorderEffect()); +} diff --git a/static/js/button-generator/effects/glitch.js b/static/js/button-generator/effects/glitch.js new file mode 100644 index 0000000..ee78f34 --- /dev/null +++ b/static/js/button-generator/effects/glitch.js @@ -0,0 +1,93 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Glitch effect + * Creates horizontal scanline displacement for a glitchy look + */ +export class GlitchEffect extends ButtonEffect { + constructor() { + super({ + id: 'glitch', + name: 'Glitch', + type: 'general', + category: 'Visual Effects', + renderOrder: 80 + }); + } + + defineControls() { + return [ + { + id: 'animate-glitch', + type: 'checkbox', + label: 'Glitch Effect', + defaultValue: false + }, + { + id: 'glitch-intensity', + type: 'range', + label: 'Glitch Intensity', + defaultValue: 5, + min: 1, + max: 20, + step: 1, + showWhen: 'animate-glitch', + description: 'Maximum pixel offset for glitch' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-glitch'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['glitch-intensity'] || 5; + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + + // Randomly glitch ~10% of scanlines per frame + const glitchProbability = 0.1; + const maxOffset = intensity; + + for (let y = 0; y < renderData.height; y++) { + if (Math.random() < glitchProbability) { + const offset = Math.floor((Math.random() - 0.5) * maxOffset * 2); + this.shiftScanline(imageData, y, offset, renderData.width); + } + } + + context.putImageData(imageData, 0, 0); + } + + /** + * Shift a horizontal scanline by offset pixels (with wrapping) + */ + shiftScanline(imageData, y, offset, width) { + const rowStart = y * width * 4; + const rowData = new Uint8ClampedArray(width * 4); + + // Copy row + for (let i = 0; i < width * 4; i++) { + rowData[i] = imageData.data[rowStart + i]; + } + + // Shift and wrap + for (let x = 0; x < width; x++) { + let sourceX = (x - offset + width) % width; + let destIdx = rowStart + x * 4; + let srcIdx = sourceX * 4; + + imageData.data[destIdx] = rowData[srcIdx]; + imageData.data[destIdx + 1] = rowData[srcIdx + 1]; + imageData.data[destIdx + 2] = rowData[srcIdx + 2]; + imageData.data[destIdx + 3] = rowData[srcIdx + 3]; + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new GlitchEffect()); +} diff --git a/static/js/button-generator/effects/hologram.js b/static/js/button-generator/effects/hologram.js new file mode 100644 index 0000000..e226aa1 --- /dev/null +++ b/static/js/button-generator/effects/hologram.js @@ -0,0 +1,170 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Hologram effect + * Creates a futuristic holographic appearance with glitches and scan lines + */ +export class HologramEffect extends ButtonEffect { + constructor() { + super({ + id: 'hologram', + name: 'Hologram', + type: 'general', + category: 'Visual Effects', + renderOrder: 88 // Near the end, after most other effects + }); + } + + defineControls() { + return [ + { + id: 'animate-hologram', + type: 'checkbox', + label: 'Hologram Effect', + defaultValue: false, + description: 'Futuristic holographic appearance' + }, + { + id: 'hologram-intensity', + type: 'range', + label: 'Effect Intensity', + defaultValue: 50, + min: 10, + max: 100, + step: 5, + showWhen: 'animate-hologram', + description: 'Strength of hologram effect' + }, + { + id: 'hologram-glitch-freq', + type: 'range', + label: 'Glitch Frequency', + defaultValue: 30, + min: 0, + max: 100, + step: 10, + showWhen: 'animate-hologram', + description: 'How often glitches occur' + }, + { + id: 'hologram-color', + type: 'color', + label: 'Hologram Tint', + defaultValue: '#00ffff', + showWhen: 'animate-hologram', + description: 'Color tint for hologram effect' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-hologram'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = (controlValues['hologram-intensity'] || 50) / 100; + const glitchFreq = (controlValues['hologram-glitch-freq'] || 30) / 100; + const color = controlValues['hologram-color'] || '#00ffff'; + + // Get current canvas content + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + const data = imageData.data; + + // Parse hologram color for tinting + const hexColor = color.replace('#', ''); + const r = parseInt(hexColor.substr(0, 2), 16); + const g = parseInt(hexColor.substr(2, 2), 16); + const b = parseInt(hexColor.substr(4, 2), 16); + + // Apply holographic tint + for (let i = 0; i < data.length; i += 4) { + // Mix with hologram color + data[i] = data[i] * (1 - intensity * 0.3) + r * intensity * 0.3; // Red + data[i + 1] = data[i + 1] * (1 - intensity * 0.5) + g * intensity * 0.5; // Green (more cyan) + data[i + 2] = data[i + 2] * (1 - intensity * 0.5) + b * intensity * 0.5; // Blue (more cyan) + } + + context.putImageData(imageData, 0, 0); + + // Add horizontal scan lines + context.globalAlpha = 0.05 * intensity; + context.fillStyle = '#000000'; + for (let y = 0; y < renderData.height; y += 2) { + context.fillRect(0, y, renderData.width, 1); + } + context.globalAlpha = 1.0; + + // Add moving highlight scan line + const scanY = (animState.progress * renderData.height) % renderData.height; + const gradient = context.createLinearGradient(0, scanY - 3, 0, scanY + 3); + gradient.addColorStop(0, 'rgba(0, 255, 255, 0)'); + gradient.addColorStop(0.5, `rgba(${r}, ${g}, ${b}, ${0.3 * intensity})`); + gradient.addColorStop(1, 'rgba(0, 255, 255, 0)'); + context.fillStyle = gradient; + context.fillRect(0, scanY - 3, renderData.width, 6); + + // Random glitches + if (Math.random() < glitchFreq * 0.1) { + const glitchY = Math.floor(Math.random() * renderData.height); + const glitchHeight = Math.floor(2 + Math.random() * 4); + const offset = (Math.random() - 0.5) * 6 * intensity; + + const sliceData = context.getImageData(0, glitchY, renderData.width, glitchHeight); + context.putImageData(sliceData, offset, glitchY); + } + + // Add chromatic aberration on edges + if (intensity > 0.3) { + const originalImage = context.getImageData(0, 0, renderData.width, renderData.height); + const aberration = 2 * intensity; + + // Slight red shift right + const redShift = context.getImageData(0, 0, renderData.width, renderData.height); + for (let i = 0; i < redShift.data.length; i += 4) { + const pixelIndex = i / 4; + const x = pixelIndex % renderData.width; + if (x < 3 || x > renderData.width - 3) { + const sourceIndex = ((pixelIndex + Math.floor(aberration)) * 4); + if (sourceIndex < originalImage.data.length) { + redShift.data[i] = originalImage.data[sourceIndex]; + } + } + } + + // Slight blue shift left + const blueShift = context.getImageData(0, 0, renderData.width, renderData.height); + for (let i = 0; i < blueShift.data.length; i += 4) { + const pixelIndex = i / 4; + const x = pixelIndex % renderData.width; + if (x < 3 || x > renderData.width - 3) { + const sourceIndex = ((pixelIndex - Math.floor(aberration)) * 4); + if (sourceIndex >= 0 && sourceIndex < originalImage.data.length) { + blueShift.data[i + 2] = originalImage.data[sourceIndex + 2]; + } + } + } + + context.putImageData(redShift, 0, 0); + context.globalCompositeOperation = 'screen'; + context.globalAlpha = 0.3; + context.putImageData(blueShift, 0, 0); + context.globalCompositeOperation = 'source-over'; + context.globalAlpha = 1.0; + } + + // Add flickering effect + if (Math.random() < 0.05) { + context.globalAlpha = 0.9 + Math.random() * 0.1; + context.fillStyle = 'rgba(255, 255, 255, 0.05)'; + context.fillRect(0, 0, renderData.width, renderData.height); + context.globalAlpha = 1.0; + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new HologramEffect()); +} diff --git a/static/js/button-generator/effects/noise.js b/static/js/button-generator/effects/noise.js new file mode 100644 index 0000000..92db24d --- /dev/null +++ b/static/js/button-generator/effects/noise.js @@ -0,0 +1,68 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Noise/Static effect + * Adds random pixel noise for a static/interference look + */ +export class NoiseEffect extends ButtonEffect { + constructor() { + super({ + id: 'noise', + name: 'Noise', + type: 'general', + category: 'Visual Effects', + renderOrder: 90 + }); + } + + defineControls() { + return [ + { + id: 'animate-noise', + type: 'checkbox', + label: 'Noise Effect', + defaultValue: false, + description: 'Random static/interference' + }, + { + id: 'noise-intensity', + type: 'range', + label: 'Noise Intensity', + defaultValue: 0.1, + min: 0.01, + max: 0.5, + step: 0.01, + showWhen: 'animate-noise', + description: 'Amount of noise' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-noise'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['noise-intensity'] || 0.1; + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + + for (let i = 0; i < imageData.data.length; i += 4) { + // Random noise value + const noise = (Math.random() - 0.5) * 255 * intensity; + + imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise)); + imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + noise)); + imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + noise)); + // Alpha unchanged + } + + context.putImageData(imageData, 0, 0); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new NoiseEffect()); +} diff --git a/static/js/button-generator/effects/pulse.js b/static/js/button-generator/effects/pulse.js new file mode 100644 index 0000000..41a745a --- /dev/null +++ b/static/js/button-generator/effects/pulse.js @@ -0,0 +1,62 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Pulse effect + * Scales the button content up and down + */ +export class PulseEffect extends ButtonEffect { + constructor() { + super({ + id: 'pulse', + name: 'Pulse', + type: 'transform', + category: 'Visual Effects', + renderOrder: 1 // Must run before any drawing + }); + } + + defineControls() { + return [ + { + id: 'animate-pulse', + type: 'checkbox', + label: 'Pulse Effect', + defaultValue: false + }, + { + id: 'pulse-scale', + type: 'range', + label: 'Pulse Scale', + defaultValue: 1.2, + min: 1.0, + max: 2.0, + step: 0.05, + showWhen: 'animate-pulse', + description: 'Maximum scale size' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-pulse'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const maxScale = controlValues['pulse-scale'] || 1.2; + const minScale = 1.0; + const scale = minScale + (maxScale - minScale) * + (Math.sin(animState.getPhase(1.0)) * 0.5 + 0.5); + + // Apply transformation (context save/restore handled by caller) + context.translate(renderData.centerX, renderData.centerY); + context.scale(scale, scale); + context.translate(-renderData.centerX, -renderData.centerY); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new PulseEffect()); +} diff --git a/static/js/button-generator/effects/rainbow-text.js b/static/js/button-generator/effects/rainbow-text.js new file mode 100644 index 0000000..5431dae --- /dev/null +++ b/static/js/button-generator/effects/rainbow-text.js @@ -0,0 +1,99 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Rainbow text animation effect + * Cycles text color through rainbow hues + */ +export class RainbowTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? '' : '2'; + super({ + id: `text-rainbow${suffix}`, + name: `Rainbow Text ${textLineNumber}`, + type: textLineNumber === 1 ? 'text' : 'text2', + category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + renderOrder: 5, // Apply before wave (lower order) + textLineNumber: textLineNumber // Pass through config + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? '' : '2'; + return [ + { + id: `animate-text-rainbow${suffix}`, + type: 'checkbox', + label: 'Rainbow Animation', + defaultValue: false + }, + { + id: `text-rainbow-speed${suffix}`, + type: 'range', + label: 'Rainbow Speed', + defaultValue: 1, + min: 0.1, + max: 5, + step: 0.1, + showWhen: `animate-text-rainbow${suffix}`, + description: 'Speed of color cycling' + } + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + return controlValues[`animate-text-rainbow${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; // Rainbow requires animation + + const suffix = this.textLineNumber === 1 ? '' : '2'; + + // Get text configuration + const text = controlValues[`button-text${suffix}`] || ''; + if (!text || text.trim() === '') return; + + // Check if wave is also enabled - if so, skip (wave will handle rainbow) + if (controlValues[`animate-text-wave${suffix}`]) return; + + const fontSize = controlValues[`font-size${suffix}`] || 12; + const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal'; + const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal'; + const fontFamily = controlValues[`font-family${suffix}`] || 'Arial'; + + const x = (controlValues[`text${suffix}-x`] / 100) * renderData.width; + const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height; + + const speed = controlValues[`text-rainbow-speed${suffix}`] || 1; + + // Calculate rainbow color + const hue = (animState.progress * speed * 360) % 360; + const fillStyle = `hsl(${hue}, 80%, 60%)`; + const strokeStyle = `hsl(${hue}, 80%, 30%)`; + + // Set font + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = strokeStyle; + context.lineWidth = 2; + context.strokeText(text, x, y); + } + + // Draw text + context.fillStyle = fillStyle; + context.fillText(text, x, y); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RainbowTextEffect(1)); + generator.registerEffect(new RainbowTextEffect(2)); +} diff --git a/static/js/button-generator/effects/rgb-split.js b/static/js/button-generator/effects/rgb-split.js new file mode 100644 index 0000000..63013a7 --- /dev/null +++ b/static/js/button-generator/effects/rgb-split.js @@ -0,0 +1,85 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * RGB Split / Chromatic Aberration effect + * Separates color channels for a glitchy chromatic aberration look + */ +export class RgbSplitEffect extends ButtonEffect { + constructor() { + super({ + id: 'rgb-split', + name: 'RGB Split', + type: 'general', + category: 'Visual Effects', + renderOrder: 85 + }); + } + + defineControls() { + return [ + { + id: 'animate-rgb-split', + type: 'checkbox', + label: 'RGB Split', + defaultValue: false, + description: 'Chromatic aberration effect' + }, + { + id: 'rgb-split-intensity', + type: 'range', + label: 'Split Intensity', + defaultValue: 2, + min: 1, + max: 10, + step: 0.5, + showWhen: 'animate-rgb-split', + description: 'Pixel offset for color channels' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-rgb-split'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['rgb-split-intensity'] || 2; + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + const result = context.createImageData(renderData.width, renderData.height); + + // Oscillating offset + const phase = Math.sin(animState.getPhase(1.0)); + const offsetX = Math.round(phase * intensity); + + for (let y = 0; y < renderData.height; y++) { + for (let x = 0; x < renderData.width; x++) { + const idx = (y * renderData.width + x) * 4; + + // Red channel - shift left + const redX = Math.max(0, Math.min(renderData.width - 1, x - offsetX)); + const redIdx = (y * renderData.width + redX) * 4; + result.data[idx] = imageData.data[redIdx]; + + // Green channel - no shift + result.data[idx + 1] = imageData.data[idx + 1]; + + // Blue channel - shift right + const blueX = Math.max(0, Math.min(renderData.width - 1, x + offsetX)); + const blueIdx = (y * renderData.width + blueX) * 4; + result.data[idx + 2] = imageData.data[blueIdx + 2]; + + // Alpha channel + result.data[idx + 3] = imageData.data[idx + 3]; + } + } + + context.putImageData(result, 0, 0); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RgbSplitEffect()); +} diff --git a/static/js/button-generator/effects/rotate.js b/static/js/button-generator/effects/rotate.js new file mode 100644 index 0000000..88012cc --- /dev/null +++ b/static/js/button-generator/effects/rotate.js @@ -0,0 +1,72 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Rotate effect + * Rotates the button back and forth + */ +export class RotateEffect extends ButtonEffect { + constructor() { + super({ + id: 'rotate', + name: 'Rotate', + type: 'transform', + category: 'Visual Effects', + renderOrder: 2 // Must run before any drawing (after pulse) + }); + } + + defineControls() { + return [ + { + id: 'animate-rotate', + type: 'checkbox', + label: 'Rotate Effect', + defaultValue: false + }, + { + id: 'rotate-angle', + type: 'range', + label: 'Max Angle', + defaultValue: 15, + min: 1, + max: 45, + step: 1, + showWhen: 'animate-rotate', + description: 'Maximum rotation angle in degrees' + }, + { + id: 'rotate-speed', + type: 'range', + label: 'Rotation Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: 'animate-rotate', + description: 'Speed of rotation' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-rotate'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const maxAngle = controlValues['rotate-angle'] || 15; + const speed = controlValues['rotate-speed'] || 1; + const angle = Math.sin(animState.getPhase(speed)) * maxAngle * (Math.PI / 180); + + // Apply transformation (context save/restore handled by caller) + context.translate(renderData.centerX, renderData.centerY); + context.rotate(angle); + context.translate(-renderData.centerX, -renderData.centerY); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RotateEffect()); +} diff --git a/static/js/button-generator/effects/scanline.js b/static/js/button-generator/effects/scanline.js new file mode 100644 index 0000000..5dab0b7 --- /dev/null +++ b/static/js/button-generator/effects/scanline.js @@ -0,0 +1,79 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Scanline effect + * Creates CRT-style horizontal lines + */ +export class ScanlineEffect extends ButtonEffect { + constructor() { + super({ + id: 'scanline', + name: 'Scanline', + type: 'general', + category: 'Visual Effects', + renderOrder: 75 + }); + } + + defineControls() { + return [ + { + id: 'animate-scanline', + type: 'checkbox', + label: 'Scanline Effect', + defaultValue: false + }, + { + id: 'scanline-intensity', + type: 'range', + label: 'Scanline Intensity', + defaultValue: 0.3, + min: 0.1, + max: 0.8, + step: 0.05, + showWhen: 'animate-scanline', + description: 'Darkness of scanlines' + }, + { + id: 'scanline-speed', + type: 'range', + label: 'Scanline Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: 'animate-scanline', + description: 'Movement speed of scanlines' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-scanline'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['scanline-intensity'] || 0.3; + const speed = controlValues['scanline-speed'] || 1; + + // Create overlay with scanlines + context.globalCompositeOperation = 'multiply'; + context.fillStyle = `rgba(0, 0, 0, ${intensity})`; + + // Animate scanline position + const offset = (animState.progress * speed * renderData.height) % 2; + + for (let y = offset; y < renderData.height; y += 2) { + context.fillRect(0, Math.floor(y), renderData.width, 1); + } + + context.globalCompositeOperation = 'source-over'; + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new ScanlineEffect()); +} diff --git a/static/js/button-generator/effects/shimmer.js b/static/js/button-generator/effects/shimmer.js new file mode 100644 index 0000000..327c124 --- /dev/null +++ b/static/js/button-generator/effects/shimmer.js @@ -0,0 +1,57 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Shimmer effect + * Creates a sweeping light/shine effect across the button + */ +export class ShimmerEffect extends ButtonEffect { + constructor() { + super({ + id: 'shimmer', + name: 'Shimmer', + type: 'general', + category: 'Visual Effects', + renderOrder: 70 + }); + } + + defineControls() { + return [ + { + id: 'animate-shimmer', + type: 'checkbox', + label: 'Shimmer Effect', + defaultValue: false, + description: 'Sweeping light effect' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-shimmer'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const shimmerX = animState.progress * (renderData.width + 40) - 20; + + const gradient = context.createLinearGradient( + shimmerX - 15, + 0, + shimmerX + 15, + renderData.height + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0)'); + gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.3)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new ShimmerEffect()); +} diff --git a/static/js/button-generator/effects/spin-text.js b/static/js/button-generator/effects/spin-text.js new file mode 100644 index 0000000..7c4b3ef --- /dev/null +++ b/static/js/button-generator/effects/spin-text.js @@ -0,0 +1,162 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Spinning text animation effect + * Makes each character rotate independently + */ +export class SpinTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? "" : "2"; + super({ + id: `text-spin${suffix}`, + name: `Spinning Text ${textLineNumber}`, + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", + renderOrder: 8, // Before wave, after rainbow + textLineNumber: textLineNumber, + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; + return [ + { + id: `animate-text-spin${suffix}`, + type: "checkbox", + label: "Spinning Animation", + defaultValue: false, + }, + { + id: `spin-speed${suffix}`, + type: "range", + label: "Spin Speed", + defaultValue: 1, + min: 1, + max: 5, + step: 1, + showWhen: `animate-text-spin${suffix}`, + description: "Speed of character rotation", + }, + { + id: `spin-stagger${suffix}`, + type: "range", + label: "Spin Stagger", + defaultValue: 0.3, + min: 0, + max: 1, + step: 0.1, + showWhen: `animate-text-spin${suffix}`, + description: "Delay between characters", + }, + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + return controlValues[`animate-text-spin${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + const text = controlValues[`button-text${suffix}`] || ""; + + if (!text || text.trim() === '') return; + if (!animState) return; + + const speed = controlValues[`spin-speed${suffix}`] || 1; + const stagger = controlValues[`spin-stagger${suffix}`] || 0.3; + const fontSize = controlValues[`font-size${suffix}`] || 14; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal"; + const fontStyle = controlValues[`text${suffix}-italic`] + ? "italic" + : "normal"; + + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Get text color + let fillStyle; + const colorType = controlValues[`text${suffix}-color-type`] || "solid"; + if (colorType === "gradient") { + const color1 = + controlValues[`text${suffix}-gradient-color1`] || "#ffffff"; + const color2 = + controlValues[`text${suffix}-gradient-color2`] || "#00ffff"; + const angle = + (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180); + const centerX = renderData.centerX; + const centerY = renderData.centerY; + const x1 = centerX + Math.cos(angle) * 20; + const y1 = centerY + Math.sin(angle) * 20; + const x2 = centerX - Math.cos(angle) * 20; + const y2 = centerY - Math.sin(angle) * 20; + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + fillStyle = gradient; + } else { + fillStyle = controlValues[`text${suffix}-color`] || "#ffffff"; + } + + // Calculate base position + const x = controlValues[`text${suffix}-x`] || 50; + const y = controlValues[`text${suffix}-y`] || 50; + const baseX = (x / 100) * renderData.width; + const baseY = (y / 100) * renderData.height; + + // Split text into grapheme clusters (handles emojis properly) + // Use Intl.Segmenter if available, otherwise fall back to spread operator + let chars; + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + chars = Array.from(segmenter.segment(text), s => s.segment); + } else { + // Fallback: spread operator handles basic emoji + chars = [...text]; + } + + // Measure total text width for centering + const totalWidth = context.measureText(text).width; + let currentX = baseX - totalWidth / 2; + + // Draw each character with rotation + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + const charWidth = context.measureText(char).width; + const charCenterX = currentX + charWidth / 2; + + // Calculate rotation for this character + const phase = animState.getPhase(speed) + i * stagger * Math.PI * 2; + const rotation = phase % (Math.PI * 2); + + context.save(); + context.translate(charCenterX, baseY); + context.rotate(rotation); + + // Apply outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = + controlValues[`text${suffix}-outline-color`] || "#000000"; + context.lineWidth = 2; + context.strokeText(char, 0, 0); + } + + context.fillStyle = fillStyle; + context.fillText(char, 0, 0); + context.restore(); + + currentX += charWidth; + } + } +} + +// Auto-register effects for both text lines +export function register(generator) { + generator.registerEffect(new SpinTextEffect(1)); + generator.registerEffect(new SpinTextEffect(2)); +} diff --git a/static/js/button-generator/effects/spotlight.js b/static/js/button-generator/effects/spotlight.js new file mode 100644 index 0000000..cfd79fb --- /dev/null +++ b/static/js/button-generator/effects/spotlight.js @@ -0,0 +1,199 @@ +/** + * EXAMPLE EFFECT + * + * This is a template for creating new effects. + * Copy this file and modify it to create your own custom effects. + * + * This example creates a "spotlight" effect that highlights a circular area + * and darkens the rest of the button. + */ + +import { ButtonEffect } from "../effect-base.js"; + +/** + * Spotlight Effect + * Creates a moving circular spotlight that highlights different areas + */ +export class SpotlightEffect extends ButtonEffect { + constructor() { + super({ + // Unique ID for this effect (used in control IDs) + id: "spotlight", + + // Display name shown in UI + name: "Spotlight", + + // Effect type determines render order category + // Options: 'background', 'border', 'text', 'text2', 'general' + type: "general", + + // Category for organizing effects in UI + category: "Visual Effects", + + // Render order within type (lower = earlier) + // 1-9: backgrounds, 10-19: borders, 20-29: transforms, + // 30-49: text, 50-79: overlays, 80-99: post-processing + renderOrder: 60, + }); + } + + /** + * Define UI controls for this effect + * These controls will be automatically bound to the generator + */ + defineControls() { + return [ + // Main enable/disable checkbox + { + id: "animate-spotlight", + type: "checkbox", + label: "Spotlight Effect", + defaultValue: false, + description: "Moving circular spotlight", + }, + + // Spotlight size control + { + id: "spotlight-size", + type: "range", + label: "Spotlight Size", + defaultValue: 20, + min: 10, + max: 50, + step: 1, + showWhen: "animate-spotlight", // Only show when checkbox is enabled + description: "Radius of the spotlight", + }, + + // Darkness of the vignette + { + id: "spotlight-darkness", + type: "range", + label: "Darkness", + defaultValue: 1, + min: 0, + max: 1, + step: 0.1, + showWhen: "animate-spotlight", + description: "How dark the non-spotlight area should be", + }, + + // Speed of movement + { + id: "spotlight-speed", + type: "range", + label: "Movement Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "animate-spotlight", + description: "Speed of spotlight movement", + }, + ]; + } + + /** + * Determine if this effect should be applied + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + isEnabled(controlValues) { + return controlValues["animate-spotlight"] === true; + } + + /** + * Apply the effect to the canvas + * + * @param {CanvasRenderingContext2D} context - Canvas 2D rendering context + * @param {Object} controlValues - Current values of all controls + * @param {AnimationState|null} animState - Animation state (null for static render) + * @param {Object} renderData - Render information: { width, height, centerX, centerY } + */ + apply(context, controlValues, animState, renderData) { + // Skip if no animation (spotlight needs movement) + if (!animState) return; + + // Get control values + const size = controlValues["spotlight-size"] || 20; + const darkness = controlValues["spotlight-darkness"] || 0.5; + const speed = controlValues["spotlight-speed"] || 1; + + // Calculate spotlight position + // Move in a circular pattern using animation phase + const phase = animState.getPhase(speed); + const spotX = renderData.centerX + Math.cos(phase) * 20; + const spotY = renderData.centerY + Math.sin(phase) * 10; + + // Create radial gradient for spotlight effect + const gradient = context.createRadialGradient( + spotX, + spotY, + 0, // Inner circle (center of spotlight) + spotX, + spotY, + size, // Outer circle (edge of spotlight) + ); + + // Center is transparent (spotlight is bright) + gradient.addColorStop(0, `rgba(0, 0, 0, 0)`); + // Edge fades to dark + gradient.addColorStop(0.5, `rgba(0, 0, 0, ${darkness * 0.3})`); + gradient.addColorStop(1, `rgba(0, 0, 0, ${darkness})`); + + // Apply the gradient as an overlay + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Optional: Add a bright center dot + context.fillStyle = "rgba(255, 255, 255, 0.3)"; + context.beginPath(); + context.arc(spotX, spotY, 2, 0, Math.PI * 2); + context.fill(); + } + + /** + * Optional: Add helper methods for your effect + */ + calculateSpotlightPath(progress, width, height) { + // Example helper method + return { + x: width * progress, + y: height / 2, + }; + } +} + +/** + * Registration function + * This is called to add the effect to the generator + * + * @param {ButtonGenerator} generator - The button generator instance + */ +export function register(generator) { + generator.registerEffect(new SpotlightEffect()); +} + +/** + * USAGE: + * + * 1. Copy this file to a new name (e.g., my-effect.js) + * 2. Modify the class name, id, and effect logic + * 3. Import in main.js: + * import * as myEffect from './effects/my-effect.js'; + * 4. Register in setupApp(): + * myEffect.register(generator); + * 5. Add HTML controls with matching IDs + */ + +/** + * TIPS: + * + * - Use animState.progress for linear animations (0 to 1) + * - Use animState.getPhase(speed) for periodic animations (0 to 2π) + * - Use Math.sin/cos for smooth periodic motion + * - Check if (!animState) at the start if your effect requires animation + * - The context is automatically saved/restored, so feel free to transform + * - Use renderData for canvas dimensions and center point + * - Look at existing effects for more examples + */ diff --git a/static/js/button-generator/effects/text-shadow.js b/static/js/button-generator/effects/text-shadow.js new file mode 100644 index 0000000..1d7a882 --- /dev/null +++ b/static/js/button-generator/effects/text-shadow.js @@ -0,0 +1,167 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Text Drop Shadow Effect + * Renders text with a drop shadow underneath + * This draws the shadow first, then standard text rendering draws on top + */ +export class TextShadowEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? "" : "2"; + super({ + id: `text-shadow${suffix}`, + name: `Drop Shadow ${textLineNumber}`, + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", + renderOrder: 19, // Before standard text (20), so shadow draws first + textLineNumber: textLineNumber, + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; + + return [ + { + id: `text${suffix}-shadow-enabled`, + type: "checkbox", + label: "Drop Shadow", + defaultValue: false, + description: + "Add drop shadow to text - Not compatible with other text effects!!", + }, + { + id: `text${suffix}-shadow-color`, + type: "color", + label: "Shadow Color", + defaultValue: "#000000", + showWhen: `text${suffix}-shadow-enabled`, + description: "Color of the shadow", + }, + { + id: `text${suffix}-shadow-blur`, + type: "range", + label: "Shadow Blur", + defaultValue: 4, + min: 0, + max: 10, + step: 1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Blur radius of shadow", + }, + { + id: `text${suffix}-shadow-offset-x`, + type: "range", + label: "Shadow X Offset", + defaultValue: 2, + min: -10, + max: 10, + step: 1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Horizontal shadow offset", + }, + { + id: `text${suffix}-shadow-offset-y`, + type: "range", + label: "Shadow Y Offset", + defaultValue: 2, + min: -10, + max: 10, + step: 1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Vertical shadow offset", + }, + { + id: `text${suffix}-shadow-opacity`, + type: "range", + label: "Shadow Opacity", + defaultValue: 0.8, + min: 0, + max: 1, + step: 0.1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Opacity of the shadow", + }, + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + const text = controlValues[`button-text${suffix}`]; + const shadowEnabled = controlValues[`text${suffix}-shadow-enabled`]; + return text && text.trim() !== "" && shadowEnabled; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + + const text = controlValues[`button-text${suffix}`] || ""; + if (!text) return; + + // Get shadow settings + const shadowColor = + controlValues[`text${suffix}-shadow-color`] || "#000000"; + const shadowBlur = controlValues[`text${suffix}-shadow-blur`] || 4; + const shadowOffsetX = controlValues[`text${suffix}-shadow-offset-x`] || 2; + const shadowOffsetY = controlValues[`text${suffix}-shadow-offset-y`] || 2; + const shadowOpacity = controlValues[`text${suffix}-shadow-opacity`] || 0.8; + + // Get text rendering settings + const fontSize = controlValues[`font-size${suffix}`] || 14; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal"; + const fontStyle = controlValues[`text${suffix}-italic`] + ? "italic" + : "normal"; + const textX = (controlValues[`text${suffix}-x`] || 50) / 100; + const textY = (controlValues[`text${suffix}-y`] || 50) / 100; + + // Convert hex to rgba + const hexToRgba = (hex, alpha) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return `rgba(0, 0, 0, ${alpha})`; + }; + + // Set up text rendering + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Calculate text position + const x = renderData.width * textX; + const y = renderData.height * textY; + + // Draw the shadow using the shadow API + // This will create a shadow underneath whatever we draw + context.shadowColor = hexToRgba(shadowColor, shadowOpacity); + context.shadowBlur = shadowBlur; + context.shadowOffsetX = shadowOffsetX; + context.shadowOffsetY = shadowOffsetY; + + // Draw a solid shadow by filling with the shadow color + // The shadow API will create the blur effect + context.fillStyle = hexToRgba(shadowColor, shadowOpacity); + context.fillText(text, x, y); + + // Reset shadow for subsequent renders + context.shadowColor = "transparent"; + context.shadowBlur = 0; + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + } +} + +// Export two instances for text line 1 and text line 2 +export function register(generator) { + generator.registerEffect(new TextShadowEffect(1)); + generator.registerEffect(new TextShadowEffect(2)); +} diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js new file mode 100644 index 0000000..fb90f87 --- /dev/null +++ b/static/js/button-generator/effects/text-standard.js @@ -0,0 +1,243 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Standard text rendering effect + * Renders static text (when no animations are active) + */ +export class StandardTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? "" : "2"; + super({ + id: `text-standard${suffix}`, + name: `Standard Text ${textLineNumber}`, + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", + renderOrder: 20, // After animations + textLineNumber: textLineNumber, // Pass through config so defineControls can access it + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + // Access from config since this is called before constructor completes + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; + return [ + { + id: `button-text${suffix}`, + type: "text", + label: `Text Line ${textLineNumber}`, + defaultValue: textLineNumber === 1 ? "RITUAL.SH" : "", + }, + { + id: `font-size${suffix}`, + type: "range", + label: "Font Size", + min: 6, + max: 24, + defaultValue: textLineNumber === 1 ? 14 : 12, + }, + { + id: `text${suffix}-x`, + type: "range", + label: "Horizontal Position", + min: 0, + max: 100, + defaultValue: 50, + description: "Percentage from left", + }, + { + id: `text${suffix}-y`, + type: "range", + label: "Vertical Position", + min: 0, + max: 100, + defaultValue: textLineNumber === 1 ? 35 : 65, + description: "Percentage from top", + }, + { + id: `text${suffix}-color-type`, + type: "select", + label: "Color Type", + defaultValue: "solid", + options: [ + { value: "solid", label: "Solid Color" }, + { value: "gradient", label: "Gradient" }, + ], + }, + { + id: `text${suffix}-color`, + type: "color", + label: "Text Color", + defaultValue: "#ffffff", + showWhen: `text${suffix}-color-type`, + }, + { + id: `text${suffix}-gradient-color1`, + type: "color", + label: "Gradient Color 1", + defaultValue: "#ffffff", + showWhen: `text${suffix}-color-type`, + }, + { + id: `text${suffix}-gradient-color2`, + type: "color", + label: "Gradient Color 2", + defaultValue: "#00ffff", + showWhen: `text${suffix}-color-type`, + }, + { + id: `text${suffix}-gradient-angle`, + type: "range", + label: "Gradient Angle", + min: 0, + max: 360, + defaultValue: 0, + showWhen: `text${suffix}-color-type`, + }, + { + id: `text${suffix}-outline`, + type: "checkbox", + label: "Outline", + defaultValue: false, + }, + { + id: `outline${suffix}-color`, + type: "color", + label: "Outline Color", + defaultValue: "#000000", + showWhen: `text${suffix}-outline`, + }, + { + id: `font-family${suffix}`, + type: "select", + label: "Font", + defaultValue: "Lato", + options: [ + { value: "Lato", label: "Lato" }, + { value: "Roboto", label: "Roboto" }, + { value: "Open Sans", label: "Open Sans" }, + { value: "Montserrat", label: "Montserrat" }, + { value: "Oswald", label: "Oswald" }, + { value: "Bebas Neue", label: "Bebas Neue" }, + { value: "Roboto Mono", label: "Roboto Mono" }, + { value: "VT323", label: "VT323" }, + { value: "Press Start 2P", label: "Press Start 2P" }, + { value: "DSEG7-Classic", label: "DSEG7" }, + ], + }, + { + id: `font-bold${suffix}`, + type: "checkbox", + label: "Bold", + defaultValue: false, + }, + { + id: `font-italic${suffix}`, + type: "checkbox", + label: "Italic", + defaultValue: false, + }, + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + const text = controlValues[`button-text${suffix}`]; + + // Only render if text exists and no animations are active on this text + const waveActive = controlValues[`animate-text-wave${suffix}`]; + const rainbowActive = controlValues[`animate-text-rainbow${suffix}`]; + const spinActive = controlValues[`animate-text-spin${suffix}`]; + + return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + + const text = controlValues[`button-text${suffix}`]; + if (!text) return; + + const fontSize = controlValues[`font-size${suffix}`] || 12; + const fontWeight = controlValues[`font-bold${suffix}`] ? "bold" : "normal"; + const fontStyle = controlValues[`font-italic${suffix}`] + ? "italic" + : "normal"; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + + const x = (controlValues[`text${suffix}-x`] / 100) * renderData.width; + const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height; + + // Set font + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Get colors + const colors = this.getTextColors( + context, + controlValues, + text, + x, + y, + fontSize, + ); + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = colors.strokeStyle; + context.lineWidth = 2; + context.strokeText(text, x, y); + } + + // Draw text + context.fillStyle = colors.fillStyle; + context.fillText(text, x, y); + } + + /** + * Get text colors (solid or gradient) + */ + getTextColors(context, controlValues, text, x, y, fontSize) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + const colorType = controlValues[`text${suffix}-color-type`] || "solid"; + + let fillStyle, strokeStyle; + + if (colorType === "solid") { + fillStyle = controlValues[`text${suffix}-color`] || "#ffffff"; + strokeStyle = controlValues[`outline${suffix}-color`] || "#000000"; + } else { + // Gradient + const angle = + (controlValues[`text${suffix}-gradient-angle`] || 0) * (Math.PI / 180); + const textWidth = context.measureText(text).width; + const x1 = x - textWidth / 2 + (Math.cos(angle) * textWidth) / 2; + const y1 = y - fontSize / 2 + (Math.sin(angle) * fontSize) / 2; + const x2 = x + textWidth / 2 - (Math.cos(angle) * textWidth) / 2; + const y2 = y + fontSize / 2 - (Math.sin(angle) * fontSize) / 2; + + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop( + 0, + controlValues[`text${suffix}-gradient-color1`] || "#ffffff", + ); + gradient.addColorStop( + 1, + controlValues[`text${suffix}-gradient-color2`] || "#00ffff", + ); + fillStyle = gradient; + strokeStyle = controlValues[`outline${suffix}-color`] || "#000000"; + } + + return { fillStyle, strokeStyle }; + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new StandardTextEffect(1)); + generator.registerEffect(new StandardTextEffect(2)); +} diff --git a/static/js/button-generator/effects/wave-text.js b/static/js/button-generator/effects/wave-text.js new file mode 100644 index 0000000..61e097c --- /dev/null +++ b/static/js/button-generator/effects/wave-text.js @@ -0,0 +1,177 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Wave text animation effect + * Makes text characters wave up and down in a sine wave pattern + */ +export class WaveTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? '' : '2'; + super({ + id: `text-wave${suffix}`, + name: `Text Wave ${textLineNumber}`, + type: textLineNumber === 1 ? 'text' : 'text2', + category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + renderOrder: 10, + textLineNumber: textLineNumber // Pass through config + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? '' : '2'; + return [ + { + id: `animate-text-wave${suffix}`, + type: 'checkbox', + label: 'Wave Animation', + defaultValue: false + }, + { + id: `wave-amplitude${suffix}`, + type: 'range', + label: 'Wave Amplitude', + defaultValue: 3, + min: 1, + max: 10, + step: 0.5, + showWhen: `animate-text-wave${suffix}`, + description: 'Height of the wave motion' + }, + { + id: `wave-speed${suffix}`, + type: 'range', + label: 'Wave Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: `animate-text-wave${suffix}`, + description: 'Speed of the wave animation' + } + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + return controlValues[`animate-text-wave${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; // Wave requires animation + + const suffix = this.textLineNumber === 1 ? '' : '2'; + + // Get text configuration + const text = controlValues[`button-text${suffix}`] || ''; + if (!text || text.trim() === '') return; + + const fontSize = controlValues[`font-size${suffix}`] || 12; + const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal'; + const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal'; + const fontFamily = controlValues[`font-family${suffix}`] || 'Arial'; + + const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width; + const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height; + + const amplitude = controlValues[`wave-amplitude${suffix}`] || 3; + const speed = controlValues[`wave-speed${suffix}`] || 1; + + // Set font + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + // Get colors + const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState); + + // Split text into grapheme clusters (handles emojis properly) + // Use Intl.Segmenter if available, otherwise fall back to spread operator + let chars; + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + chars = Array.from(segmenter.segment(text), s => s.segment); + } else { + // Fallback: spread operator handles basic emoji + chars = [...text]; + } + + // Measure total width for centering + const totalWidth = context.measureText(text).width; + let currentX = baseX - totalWidth / 2; + + // Draw each character with wave offset + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + const charWidth = context.measureText(char).width; + + // Calculate wave offset for this character + const phase = animState.getPhase(speed); + const charOffset = i / chars.length; + const waveY = Math.sin(phase + charOffset * Math.PI * 2) * amplitude; + + const charX = currentX + charWidth / 2; + const charY = baseY + waveY; + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = colors.strokeStyle; + context.lineWidth = 2; + context.strokeText(char, charX, charY); + } + + // Draw character + context.fillStyle = colors.fillStyle; + context.fillText(char, charX, charY); + + currentX += charWidth; + } + } + + /** + * Get text colors (solid, gradient, or rainbow) + */ + getTextColors(context, controlValues, text, x, y, fontSize, animState) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + + let fillStyle, strokeStyle; + + // Check if rainbow text is also enabled + if (animState && controlValues[`animate-text-rainbow${suffix}`]) { + const speed = controlValues[`text-rainbow-speed${suffix}`] || 1; + const hue = (animState.progress * speed * 360) % 360; + fillStyle = `hsl(${hue}, 80%, 60%)`; + strokeStyle = `hsl(${hue}, 80%, 30%)`; + } else { + const colorType = controlValues[`text${suffix}-color-type`] || 'solid'; + + if (colorType === 'solid') { + fillStyle = controlValues[`text${suffix}-color`] || '#ffffff'; + strokeStyle = controlValues[`outline${suffix}-color`] || '#000000'; + } else { + // Gradient + const angle = (controlValues[`text${suffix}-gradient-angle`] || 0) * (Math.PI / 180); + const textWidth = context.measureText(text).width; + const x1 = x - textWidth / 2 + (Math.cos(angle) * textWidth) / 2; + const y1 = y - fontSize / 2 + (Math.sin(angle) * fontSize) / 2; + const x2 = x + textWidth / 2 - (Math.cos(angle) * textWidth) / 2; + const y2 = y + fontSize / 2 - (Math.sin(angle) * fontSize) / 2; + + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, controlValues[`text${suffix}-gradient-color1`] || '#ffffff'); + gradient.addColorStop(1, controlValues[`text${suffix}-gradient-color2`] || '#00ffff'); + fillStyle = gradient; + strokeStyle = controlValues[`outline${suffix}-color`] || '#000000'; + } + } + + return { fillStyle, strokeStyle }; + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new WaveTextEffect(1)); + generator.registerEffect(new WaveTextEffect(2)); +} diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js new file mode 100644 index 0000000..030236d --- /dev/null +++ b/static/js/button-generator/main.js @@ -0,0 +1,472 @@ +/** + * Button Generator - Main Application File + * + * This demonstrates how to use the modular button generator system. + * Effects are imported and registered with the generator. + */ + +import { ButtonGenerator } from "./button-generator-core.js"; +import { UIBuilder } from "./ui-builder.js"; + +// Import effects (each effect file exports a register() function) +import * as solidBg from "./effects/background-solid.js"; +import * as gradientBg from "./effects/background-gradient.js"; +import * as textureBg from "./effects/background-texture.js"; +import * as emojiWallpaper from "./effects/background-emoji-wallpaper.js"; +import * as externalImage from "./effects/background-external-image.js"; +import * as rainbowBg from "./effects/background-rainbow.js"; +import * as rain from "./effects/background-rain.js"; +import * as starfield from "./effects/background-starfield.js"; +//import * as bubbles from "./effects/background-bubbles.js"; +import * as aurora from "./effects/background-aurora.js"; +import * as fire from "./effects/background-fire.js"; +import * as border from "./effects/border.js"; +import * as standardText from "./effects/text-standard.js"; +import * as textShadow from "./effects/text-shadow.js"; +import * as waveText from "./effects/wave-text.js"; +import * as rainbowText from "./effects/rainbow-text.js"; +import * as spinText from "./effects/spin-text.js"; +import * as glitch from "./effects/glitch.js"; +import * as pulse from "./effects/pulse.js"; +import * as shimmer from "./effects/shimmer.js"; +import * as scanline from "./effects/scanline.js"; +import * as rgbSplit from "./effects/rgb-split.js"; +import * as noise from "./effects/noise.js"; +import * as rotate from "./effects/rotate.js"; +import * as hologram from "./effects/hologram.js"; +import * as spotlight from "./effects/spotlight.js"; + +/** + * Initialize the button generator application + */ +export function init() { + // Wait for DOM to be ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupApp); + } else { + setupApp(); + } +} + +/** + * Setup the application + */ +async function setupApp() { + console.log("Initializing Button Generator..."); + + // Setup collapsible sections + setupCollapsible(); + + // Get canvas + const canvas = document.getElementById("button-canvas"); + if (!canvas) { + console.error("Canvas element not found!"); + return; + } + + // Create button generator + const generator = new ButtonGenerator(canvas, { + fps: 20, + duration: 2, + fonts: [ + "Arial", + "Lato", + "Roboto", + "Open Sans", + "Montserrat", + "Oswald", + "Bebas Neue", + "Roboto Mono", + "VT323", + "Press Start 2P", + "DSEG7-Classic", + ], + }); + + // Register all effects + console.log("Registering effects..."); + solidBg.register(generator); + gradientBg.register(generator); + textureBg.register(generator); + emojiWallpaper.register(generator); + externalImage.register(generator); + rainbowBg.register(generator); + rain.register(generator); + starfield.register(generator); + //bubbles.register(generator); + aurora.register(generator); + fire.register(generator); + border.register(generator); + standardText.register(generator); + textShadow.register(generator); + waveText.register(generator); + rainbowText.register(generator); + spinText.register(generator); + glitch.register(generator); + pulse.register(generator); + shimmer.register(generator); + scanline.register(generator); + rgbSplit.register(generator); + noise.register(generator); + rotate.register(generator); + hologram.register(generator); + spotlight.register(generator); + + console.log(`Registered ${generator.getAllEffects().length} effects`); + + // Build UI from effects + console.log("Building UI..."); + const controlsContainer = document.querySelector(".controls-section"); + if (!controlsContainer) { + console.error("Controls container not found!"); + return; + } + + const uiBuilder = new UIBuilder(controlsContainer); + uiBuilder.buildUI(generator.getAllEffects()); + uiBuilder.setupConditionalVisibility(); + + // Add GIF export settings at the bottom + addGifExportSettings(controlsContainer, generator); + + // Preload fonts + console.log("Loading fonts..."); + await generator.preloadFonts(); + + // Bind controls (after UI is built) + generator.bindControls(); + + // Setup additional UI handlers + setupUIHandlers(generator); + + // Setup download button + setupDownloadButton(generator); + + // Setup presets + setupPresets(generator); + + // Initial draw + generator.updatePreview(); + + console.log("Button Generator ready!"); +} + +/** + * Add GIF export settings controls + */ +function addGifExportSettings(container, generator) { + const groupDiv = document.createElement('div'); + groupDiv.className = 'control-group collapsed'; + + // Header + const header = document.createElement('h3'); + header.className = 'control-group-header'; + header.textContent = 'Advanced Settings'; + groupDiv.appendChild(header); + + const controlsDiv = document.createElement('div'); + controlsDiv.className = 'control-group-controls'; + + // Color count control + const colorsWrapper = document.createElement('div'); + colorsWrapper.className = 'control-wrapper'; + colorsWrapper.innerHTML = ` + <div class="info-text">Note: This only affects frame-by-frame settings, i.e. 8 colours would be 8 colours per frame. I am working on a solution for this.</div> + <label for="gif-colors"> + Color Count + <span class="control-description">(16-32 for the 90s experience)</span> + </label> + <div class="range-container"> + <input type="range" id="gif-colors" min="2" max="256" step="1" value="256"> + <span id="gif-colors-value" class="range-value">256</span> + </div> + `; + controlsDiv.appendChild(colorsWrapper); + + // Quality control + const qualityWrapper = document.createElement('div'); + qualityWrapper.className = 'control-wrapper'; + qualityWrapper.innerHTML = ` + <label for="gif-quality"> + Quantization Quality + <span class="control-description">(lower = better but slower export)</span> + </label> + <div class="range-container"> + <input type="range" id="gif-quality" min="1" max="30" step="1" value="1"> + <span id="gif-quality-value" class="range-value">1</span> + </div> + `; + controlsDiv.appendChild(qualityWrapper); + + // Dither control + const ditherWrapper = document.createElement('div'); + ditherWrapper.className = 'control-wrapper'; + ditherWrapper.innerHTML = ` + <label for="gif-dither"> + Dithering Pattern + <span class="control-description"></span> + </label> + <select id="gif-dither"> + <option value="false">None (Smooth)</option> + <option value="FloydSteinberg">Floyd-Steinberg (Classic)</option> + <option value="FalseFloydSteinberg">False Floyd-Steinberg (Fast)</option> + <option value="Stucki">Stucki (Distributed)</option> + <option value="Atkinson">Atkinson (Retro Mac)</option> + </select> + `; + controlsDiv.appendChild(ditherWrapper); + + groupDiv.appendChild(controlsDiv); + container.appendChild(groupDiv); + + // Setup event listeners for value display + const colorsInput = document.getElementById('gif-colors'); + const colorsValue = document.getElementById('gif-colors-value'); + colorsInput.addEventListener('input', () => { + colorsValue.textContent = colorsInput.value; + }); + + const qualityInput = document.getElementById('gif-quality'); + const qualityValue = document.getElementById('gif-quality-value'); + qualityInput.addEventListener('input', () => { + qualityValue.textContent = qualityInput.value; + }); + + // Update generator config when changed + colorsInput.addEventListener('change', () => { + generator.gifConfig.colorCount = parseInt(colorsInput.value); + generator.updatePreview(); // Update preview to show color quantization + }); + + // Also update preview on slider input for real-time feedback + colorsInput.addEventListener('input', () => { + generator.gifConfig.colorCount = parseInt(colorsInput.value); + generator.updatePreview(); // Real-time preview update + }); + + qualityInput.addEventListener('change', () => { + generator.gifConfig.quality = parseInt(qualityInput.value); + }); + + const ditherSelect = document.getElementById('gif-dither'); + ditherSelect.addEventListener('change', () => { + const value = ditherSelect.value; + generator.gifConfig.dither = value === 'false' ? false : value; + }); +} + +/** + * Setup collapsible section functionality + */ +function setupCollapsible() { + const headers = document.querySelectorAll(".control-group-header"); + console.log("Found", headers.length, "collapsible headers"); + + headers.forEach((header) => { + header.addEventListener("click", () => { + const controlGroup = header.closest(".control-group"); + if (controlGroup) { + controlGroup.classList.toggle("collapsed"); + } + }); + }); +} + +/** + * Setup UI handlers for conditional visibility + */ +function setupUIHandlers(generator) { + // Note: Conditional visibility is now handled automatically by UIBuilder.setupConditionalVisibility() + // This function is kept for any additional custom handlers if needed in the future +} + +/** + * Setup download button + */ +function setupDownloadButton(generator) { + const downloadBtn = document.getElementById("download-button"); + if (!downloadBtn) return; + + downloadBtn.addEventListener("click", async () => { + const originalText = downloadBtn.textContent; + downloadBtn.disabled = true; + downloadBtn.textContent = "Generating GIF..."; + + try { + const blob = await generator.exportAsGif((progress, stage) => { + if (stage === "generating") { + const percent = Math.round(progress * 100); + downloadBtn.textContent = `Generating: ${percent}%`; + } else if (stage === "encoding") { + const percent = Math.round(progress * 100); + downloadBtn.textContent = `Encoding: ${percent}%`; + } + }); + + // Download the blob + const link = document.createElement("a"); + link.download = "button-88x31.gif"; + link.href = URL.createObjectURL(blob); + link.click(); + URL.revokeObjectURL(link.href); + + downloadBtn.textContent = originalText; + } catch (error) { + console.error("Error generating GIF:", error); + alert("Error generating GIF. Please try again."); + downloadBtn.textContent = originalText; + } finally { + downloadBtn.disabled = false; + } + }); +} + +/** + * Setup preset buttons + */ +function setupPresets(generator) { + // Random preset + const randomBtn = document.getElementById("preset-random"); + if (randomBtn) { + randomBtn.addEventListener("click", () => { + applyRandomPreset(); + generator.updatePreview(); + }); + } + + // Classic preset + const classicBtn = document.getElementById("preset-classic"); + if (classicBtn) { + classicBtn.addEventListener("click", () => { + applyClassicPreset(); + generator.updatePreview(); + }); + } + + // Modern preset + const modernBtn = document.getElementById("preset-modern"); + if (modernBtn) { + modernBtn.addEventListener("click", () => { + applyModernPreset(); + generator.updatePreview(); + }); + } +} + +/** + * Apply random preset + */ +function applyRandomPreset() { + const randomColor = () => + "#" + + Math.floor(Math.random() * 16777215) + .toString(16) + .padStart(6, "0"); + + // Random background + const bgTypeEl = document.getElementById("bg-type"); + if (bgTypeEl) { + bgTypeEl.value = ["solid", "gradient", "texture"][ + Math.floor(Math.random() * 3) + ]; + bgTypeEl.dispatchEvent(new Event("change")); + } + + setControlValue("bg-color", randomColor()); + setControlValue("gradient-color1", randomColor()); + setControlValue("gradient-color2", randomColor()); + setControlValue("gradient-angle", Math.floor(Math.random() * 360)); + + // Random text colors + setControlValue("text-color", randomColor()); + setControlValue("text-gradient-color1", randomColor()); + setControlValue("text-gradient-color2", randomColor()); + setControlValue("text2-color", randomColor()); + setControlValue("text2-gradient-color1", randomColor()); + setControlValue("text2-gradient-color2", randomColor()); + + // Random border + setControlValue("border-color", randomColor()); + setControlValue("border-width", Math.floor(Math.random() * 6)); +} + +/** + * Apply classic 90s preset + */ +function applyClassicPreset() { + setControlValue("bg-type", "gradient"); + setControlValue("gradient-color1", "#6e6e6eff"); + setControlValue("gradient-color2", "#979797"); + setControlValue("gradient-angle", 90); + + setControlValue("text-color-type", "solid"); + setControlValue("text-color", "#000000"); + setControlValue("text2-color-type", "solid"); + setControlValue("text2-color", "#000000"); + + setControlValue("border-width", 2); + setControlValue("border-color", "#000000"); + setControlValue("border-style", "outset"); + + setControlValue("font-family", "VT323"); + setControlValue("font-family2", "VT323"); + + document.getElementById("bg-type")?.dispatchEvent(new Event("change")); +} + +/** + * Apply modern preset + */ +function applyModernPreset() { + setControlValue("bg-type", "gradient"); + setControlValue("gradient-color1", "#0a0a0a"); + setControlValue("gradient-color2", "#1a0a2e"); + setControlValue("gradient-angle", 135); + + setControlValue("text-color-type", "gradient"); + setControlValue("text-gradient-color1", "#00ffaa"); + setControlValue("text-gradient-color2", "#00ffff"); + setControlValue("text-gradient-angle", 90); + + setControlValue("text2-color-type", "gradient"); + setControlValue("text2-gradient-color1", "#ff00ff"); + setControlValue("text2-gradient-color2", "#ff6600"); + + setControlValue("border-width", 1); + setControlValue("border-color", "#00ffaa"); + setControlValue("border-style", "solid"); + + setControlValue("font-family", "Roboto Mono"); + setControlValue("font-family2", "Roboto Mono"); + + document.getElementById("bg-type")?.dispatchEvent(new Event("change")); + document + .getElementById("text-color-type") + ?.dispatchEvent(new Event("change")); + document + .getElementById("text2-color-type") + ?.dispatchEvent(new Event("change")); +} + +/** + * Helper to set control value + */ +function setControlValue(id, value) { + const el = document.getElementById(id); + if (el) { + if (el.type === "checkbox") { + el.checked = value; + } else { + el.value = value; + } + + // Update value display if it exists + const valueDisplay = document.getElementById(id + "-value"); + if (valueDisplay) { + valueDisplay.textContent = value; + } + } +} + +// Auto-initialize when imported +init(); diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js new file mode 100644 index 0000000..2ab5e1d --- /dev/null +++ b/static/js/button-generator/ui-builder.js @@ -0,0 +1,572 @@ +/** + * UI Builder - Dynamically generates control UI from effect definitions + */ + +export class UIBuilder { + constructor(containerElement) { + this.container = containerElement; + this.controlGroups = new Map(); // category -> { element, controls } + this.tooltip = null; + this.tooltipTimeout = null; + this.setupTooltip(); + } + + /** + * Create and setup the tooltip element + */ + setupTooltip() { + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + this.createTooltipElement(); + }); + } else { + this.createTooltipElement(); + } + } + + createTooltipElement() { + this.tooltip = document.createElement('div'); + this.tooltip.className = 'control-tooltip'; + this.tooltip.style.cssText = ` + position: fixed; + background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%); + color: #fff; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.8rem; + pointer-events: none; + z-index: 10000; + max-width: 250px; + box-shadow: 0 0 20px rgba(0, 150, 255, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(0, 150, 255, 0.6); + opacity: 0; + transition: opacity 0.15s ease; + line-height: 1.4; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + `; + document.body.appendChild(this.tooltip); + } + + /** + * Show tooltip for an element + */ + showTooltip(element, text) { + if (!text || !this.tooltip) return; + + clearTimeout(this.tooltipTimeout); + + this.tooltip.textContent = text; + this.tooltip.style.opacity = '1'; + + // Position tooltip above the element + const rect = element.getBoundingClientRect(); + + // Set initial position to measure + this.tooltip.style.left = '0px'; + this.tooltip.style.top = '0px'; + this.tooltip.style.visibility = 'hidden'; + this.tooltip.style.display = 'block'; + + const tooltipRect = this.tooltip.getBoundingClientRect(); + + this.tooltip.style.visibility = 'visible'; + + let left = rect.left + rect.width / 2 - tooltipRect.width / 2; + let top = rect.top - tooltipRect.height - 10; + + // Keep tooltip on screen + const padding = 10; + if (left < padding) left = padding; + if (left + tooltipRect.width > window.innerWidth - padding) { + left = window.innerWidth - tooltipRect.width - padding; + } + if (top < padding) { + top = rect.bottom + 10; + } + + this.tooltip.style.left = `${left}px`; + this.tooltip.style.top = `${top}px`; + } + + /** + * Hide tooltip + */ + hideTooltip() { + if (!this.tooltip) return; + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = setTimeout(() => { + this.tooltip.style.opacity = '0'; + }, 100); + } + + /** + * Add tooltip handlers to an element + */ + addTooltipHandlers(element, description) { + if (!description) return; + + element.addEventListener('mouseenter', () => { + this.showTooltip(element, description); + }); + + element.addEventListener('mouseleave', () => { + this.hideTooltip(); + }); + + element.addEventListener('mousemove', () => { + // Update position on mouse move for better following + if (this.tooltip && this.tooltip.style.opacity === '1') { + this.showTooltip(element, description); + } + }); + } + + /** + * Build the entire UI from registered effects + * @param {Array<ButtonEffect>} effects - All registered effects + */ + buildUI(effects) { + // Clear existing content + this.container.innerHTML = ''; + this.controlGroups.clear(); + + // Group effects by category + const categorized = this.categorizeEffects(effects); + + // Create control groups for each category + for (const [category, categoryEffects] of categorized) { + this.createControlGroup(category, categoryEffects); + } + } + + /** + * Categorize effects by their category property + * @param {Array<ButtonEffect>} effects + * @returns {Map<string, Array<ButtonEffect>>} + */ + categorizeEffects(effects) { + const categories = new Map(); + + effects.forEach(effect => { + const category = effect.category || 'Other'; + if (!categories.has(category)) { + categories.set(category, []); + } + categories.get(category).push(effect); + }); + + // Sort categories in a logical order + const orderedCategories = new Map(); + const categoryOrder = [ + 'Text Line 1', + 'Text Line 2', + 'Background', + 'Background Animations', + 'Border', + 'Visual Effects', + 'General Effects', + 'Special Effects' + ]; + + categoryOrder.forEach(cat => { + if (categories.has(cat)) { + orderedCategories.set(cat, categories.get(cat)); + } + }); + + // Add any remaining categories + categories.forEach((effects, cat) => { + if (!orderedCategories.has(cat)) { + orderedCategories.set(cat, effects); + } + }); + + return orderedCategories; + } + + /** + * Create a collapsible control group + * @param {string} category - Category name + * @param {Array<ButtonEffect>} effects - Effects in this category + */ + createControlGroup(category, effects) { + const groupDiv = document.createElement('div'); + groupDiv.className = 'control-group'; + + // Create header + const header = document.createElement('h3'); + header.className = 'control-group-header'; + header.innerHTML = ` + <span>${category}</span> + <span class="toggle-icon">−</span> + `; + + // Create content container + const content = document.createElement('div'); + content.className = 'control-group-content'; + + // Add controls for each effect in this category + effects.forEach(effect => { + this.addEffectControls(content, effect); + }); + + // Add click handler for collapsing + header.addEventListener('click', () => { + groupDiv.classList.toggle('collapsed'); + }); + + groupDiv.appendChild(header); + groupDiv.appendChild(content); + this.container.appendChild(groupDiv); + + this.controlGroups.set(category, { element: groupDiv, effects }); + } + + /** + * Add controls for a single effect + * @param {HTMLElement} container - Container to add controls to + * @param {ButtonEffect} effect - Effect to create controls for + */ + addEffectControls(container, effect) { + effect.controls.forEach(control => { + const controlEl = this.createControl(control); + if (controlEl) { + container.appendChild(controlEl); + } + }); + } + + /** + * Create a single control element + * @param {Object} controlDef - Control definition from effect + * @returns {HTMLElement} + */ + createControl(controlDef) { + const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = controlDef; + + switch (type) { + case 'checkbox': + return this.createCheckbox(id, label, defaultValue, showWhen, description); + + case 'range': + return this.createRange(id, label, defaultValue, min, max, step, description, showWhen); + + case 'color': + return this.createColor(id, label, defaultValue, showWhen, description); + + case 'select': + return this.createSelect(id, label, defaultValue, options, showWhen, description); + + case 'text': + return this.createTextInput(id, label, defaultValue, showWhen, description); + + case 'file': + return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept); + + default: + console.warn(`Unknown control type: ${type}`); + return null; + } + } + + /** + * Create a checkbox control + */ + createCheckbox(id, label, defaultValue, showWhen, description) { + const wrapper = document.createElement('label'); + wrapper.className = 'checkbox-label'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = id; + input.checked = defaultValue || false; + + const span = document.createElement('span'); + span.textContent = label; + + wrapper.appendChild(input); + wrapper.appendChild(span); + + if (showWhen) { + wrapper.style.display = 'none'; + wrapper.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label wrapper + this.addTooltipHandlers(wrapper, description); + + return wrapper; + } + + /** + * Create a range slider control + */ + createRange(id, label, defaultValue, min, max, step, description, showWhen) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.innerHTML = `${label}: <span id="${id}-value">${defaultValue}</span>`; + + const input = document.createElement('input'); + input.type = 'range'; + input.id = id; + input.min = min !== undefined ? min : 0; + input.max = max !== undefined ? max : 100; + input.value = defaultValue !== undefined ? defaultValue : 50; + if (step !== undefined) { + input.step = step; + } + + // Update value display on input + input.addEventListener('input', () => { + const valueDisplay = document.getElementById(`${id}-value`); + if (valueDisplay) { + valueDisplay.textContent = input.value; + } + }); + + container.appendChild(labelEl); + container.appendChild(input); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + + return container; + } + + /** + * Create a color picker control + */ + createColor(id, label, defaultValue, showWhen, description) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const input = document.createElement('input'); + input.type = 'color'; + input.id = id; + input.value = defaultValue || '#ffffff'; + + container.appendChild(labelEl); + container.appendChild(input); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + + return container; + } + + /** + * Create a select dropdown control + */ + createSelect(id, label, defaultValue, options, showWhen, description) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const select = document.createElement('select'); + select.id = id; + + options.forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === defaultValue) { + option.selected = true; + } + select.appendChild(option); + }); + + container.appendChild(labelEl); + container.appendChild(select); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + + return container; + } + + /** + * Create a text input control + */ + createTextInput(id, label, defaultValue, showWhen, description) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const input = document.createElement('input'); + input.type = 'text'; + input.id = id; + input.value = defaultValue || ''; + + // Only set maxLength for text inputs that aren't URLs + if (id !== 'bg-image-url') { + input.maxLength = 20; + } + + container.appendChild(labelEl); + container.appendChild(input); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + + return container; + } + + /** + * Create a file input control + */ + createFileInput(id, label, defaultValue, showWhen, description, accept) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const input = document.createElement('input'); + input.type = 'file'; + input.id = id; + if (accept) { + input.accept = accept; + } + + // Store the file data on the input element + input.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + // Create a blob URL for the file + const blobUrl = URL.createObjectURL(file); + // Store file metadata on the input element + input.dataset.fileName = file.name; + input.dataset.blobUrl = blobUrl; + input.dataset.fileSize = file.size; + input.dataset.fileType = file.type; + } else { + // Clear the data if no file is selected + delete input.dataset.fileName; + delete input.dataset.blobUrl; + delete input.dataset.fileSize; + delete input.dataset.fileType; + } + }); + + container.appendChild(labelEl); + container.appendChild(input); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + + return container; + } + + /** + * Setup conditional visibility for controls + * Should be called after all controls are created + */ + setupConditionalVisibility() { + // Find all controls with showWhen attribute + const conditionalControls = this.container.querySelectorAll('[data-show-when]'); + + conditionalControls.forEach(control => { + const triggerControlId = control.dataset.showWhen; + const triggerControl = document.getElementById(triggerControlId); + + if (triggerControl) { + const updateVisibility = () => { + if (triggerControl.type === 'checkbox') { + control.style.display = triggerControl.checked ? 'block' : 'none'; + } else if (triggerControl.tagName === 'SELECT') { + // Get the control ID to determine what value to check for + const controlId = control.querySelector('input, select')?.id; + + // For background controls + if (triggerControlId === 'bg-type') { + if (controlId === 'bg-color') { + control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('gradient-') || controlId === 'gradient-angle')) { + control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('texture-') || controlId === 'texture-type' || controlId === 'texture-scale')) { + control.style.display = triggerControl.value === 'texture' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) { + control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-file' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) { + control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none'; + } + } + // For image fit controls (zoom and position only show when manual mode) + else if (triggerControlId === 'bg-image-fit') { + if (controlId && (controlId === 'bg-image-zoom' || controlId === 'bg-image-offset-x' || controlId === 'bg-image-offset-y')) { + control.style.display = triggerControl.value === 'manual' ? 'block' : 'none'; + } + } + // For text color controls + else if (triggerControlId === 'text-color-type') { + if (controlId === 'text-color') { + control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; + } else if (controlId && controlId.startsWith('text-gradient-')) { + control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; + } + } else if (triggerControlId === 'text2-color-type') { + if (controlId === 'text2-color') { + control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; + } else if (controlId && controlId.startsWith('text2-gradient-')) { + control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; + } + } + // For border style controls + else if (triggerControlId === 'border-style') { + if (controlId === 'border-rainbow-speed') { + control.style.display = triggerControl.value === 'rainbow' ? 'block' : 'none'; + } else if (controlId === 'border-march-speed') { + control.style.display = triggerControl.value === 'marching-ants' ? 'block' : 'none'; + } + } else { + // Default: show when any value is selected + control.style.display = triggerControl.value ? 'block' : 'none'; + } + } + }; + + // Initial visibility + updateVisibility(); + + // Update on change + triggerControl.addEventListener('change', updateVisibility); + triggerControl.addEventListener('input', updateVisibility); + } + }); + } +} diff --git a/static/js/footnote-scroll.js b/static/js/footnote-scroll.js new file mode 100644 index 0000000..0c58b59 --- /dev/null +++ b/static/js/footnote-scroll.js @@ -0,0 +1,32 @@ +(function() { + // Handle smooth scrolling for all footnote links (both directions) + document.addEventListener('DOMContentLoaded', function() { + // Get all footnote links (both references and backlinks) + const footnoteLinks = document.querySelectorAll('a[href^="#fn:"], a[href^="#fnref:"]'); + + footnoteLinks.forEach(function(link) { + link.addEventListener('click', function(e) { + e.preventDefault(); + + const targetId = this.getAttribute('href').substring(1); + const targetElement = document.getElementById(targetId); + + if (targetElement) { + // Calculate position with offset + const offset = 100; // pixels from top + const elementPosition = targetElement.getBoundingClientRect().top; + const offsetPosition = elementPosition + window.pageYOffset - offset; + + // Smooth scroll to position + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + + // Update URL hash without jumping + history.pushState(null, null, this.getAttribute('href')); + } + }); + }); + }); +})(); diff --git a/static/js/gif.js b/static/js/gif.js new file mode 100644 index 0000000..2e4d204 --- /dev/null +++ b/static/js/gif.js @@ -0,0 +1,3 @@ +// gif.js 0.2.0 - https://github.com/jnordberg/gif.js +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GIF=f()}})(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s}({1:[function(require,module,exports){function EventEmitter(){this._events=this._events||{};this._maxListeners=this._maxListeners||undefined}module.exports=EventEmitter;EventEmitter.EventEmitter=EventEmitter;EventEmitter.prototype._events=undefined;EventEmitter.prototype._maxListeners=undefined;EventEmitter.defaultMaxListeners=10;EventEmitter.prototype.setMaxListeners=function(n){if(!isNumber(n)||n<0||isNaN(n))throw TypeError("n must be a positive number");this._maxListeners=n;return this};EventEmitter.prototype.emit=function(type){var er,handler,len,args,i,listeners;if(!this._events)this._events={};if(type==="error"){if(!this._events.error||isObject(this._events.error)&&!this._events.error.length){er=arguments[1];if(er instanceof Error){throw er}else{var err=new Error('Uncaught, unspecified "error" event. ('+er+")");err.context=er;throw err}}}handler=this._events[type];if(isUndefined(handler))return false;if(isFunction(handler)){switch(arguments.length){case 1:handler.call(this);break;case 2:handler.call(this,arguments[1]);break;case 3:handler.call(this,arguments[1],arguments[2]);break;default:args=Array.prototype.slice.call(arguments,1);handler.apply(this,args)}}else if(isObject(handler)){args=Array.prototype.slice.call(arguments,1);listeners=handler.slice();len=listeners.length;for(i=0;i<len;i++)listeners[i].apply(this,args)}return true};EventEmitter.prototype.addListener=function(type,listener){var m;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events)this._events={};if(this._events.newListener)this.emit("newListener",type,isFunction(listener.listener)?listener.listener:listener);if(!this._events[type])this._events[type]=listener;else if(isObject(this._events[type]))this._events[type].push(listener);else this._events[type]=[this._events[type],listener];if(isObject(this._events[type])&&!this._events[type].warned){if(!isUndefined(this._maxListeners)){m=this._maxListeners}else{m=EventEmitter.defaultMaxListeners}if(m&&m>0&&this._events[type].length>m){this._events[type].warned=true;console.error("(node) warning: possible EventEmitter memory "+"leak detected. %d listeners added. "+"Use emitter.setMaxListeners() to increase limit.",this._events[type].length);if(typeof console.trace==="function"){console.trace()}}}return this};EventEmitter.prototype.on=EventEmitter.prototype.addListener;EventEmitter.prototype.once=function(type,listener){if(!isFunction(listener))throw TypeError("listener must be a function");var fired=false;function g(){this.removeListener(type,g);if(!fired){fired=true;listener.apply(this,arguments)}}g.listener=listener;this.on(type,g);return this};EventEmitter.prototype.removeListener=function(type,listener){var list,position,length,i;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events||!this._events[type])return this;list=this._events[type];length=list.length;position=-1;if(list===listener||isFunction(list.listener)&&list.listener===listener){delete this._events[type];if(this._events.removeListener)this.emit("removeListener",type,listener)}else if(isObject(list)){for(i=length;i-- >0;){if(list[i]===listener||list[i].listener&&list[i].listener===listener){position=i;break}}if(position<0)return this;if(list.length===1){list.length=0;delete this._events[type]}else{list.splice(position,1)}if(this._events.removeListener)this.emit("removeListener",type,listener)}return this};EventEmitter.prototype.removeAllListeners=function(type){var key,listeners;if(!this._events)return this;if(!this._events.removeListener){if(arguments.length===0)this._events={};else if(this._events[type])delete this._events[type];return this}if(arguments.length===0){for(key in this._events){if(key==="removeListener")continue;this.removeAllListeners(key)}this.removeAllListeners("removeListener");this._events={};return this}listeners=this._events[type];if(isFunction(listeners)){this.removeListener(type,listeners)}else if(listeners){while(listeners.length)this.removeListener(type,listeners[listeners.length-1])}delete this._events[type];return this};EventEmitter.prototype.listeners=function(type){var ret;if(!this._events||!this._events[type])ret=[];else if(isFunction(this._events[type]))ret=[this._events[type]];else ret=this._events[type].slice();return ret};EventEmitter.prototype.listenerCount=function(type){if(this._events){var evlistener=this._events[type];if(isFunction(evlistener))return 1;else if(evlistener)return evlistener.length}return 0};EventEmitter.listenerCount=function(emitter,type){return emitter.listenerCount(type)};function isFunction(arg){return typeof arg==="function"}function isNumber(arg){return typeof arg==="number"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isUndefined(arg){return arg===void 0}},{}],2:[function(require,module,exports){var UA,browser,mode,platform,ua;ua=navigator.userAgent.toLowerCase();platform=navigator.platform.toLowerCase();UA=ua.match(/(opera|ie|firefox|chrome|version)[\s\/:]([\w\d\.]+)?.*?(safari|version[\s\/:]([\w\d\.]+)|$)/)||[null,"unknown",0];mode=UA[1]==="ie"&&document.documentMode;browser={name:UA[1]==="version"?UA[3]:UA[1],version:mode||parseFloat(UA[1]==="opera"&&UA[4]?UA[4]:UA[2]),platform:{name:ua.match(/ip(?:ad|od|hone)/)?"ios":(ua.match(/(?:webos|android)/)||platform.match(/mac|win|linux/)||["other"])[0]}};browser[browser.name]=true;browser[browser.name+parseInt(browser.version,10)]=true;browser.platform[browser.platform.name]=true;module.exports=browser},{}],3:[function(require,module,exports){var EventEmitter,GIF,browser,extend=function(child,parent){for(var key in parent){if(hasProp.call(parent,key))child[key]=parent[key]}function ctor(){this.constructor=child}ctor.prototype=parent.prototype;child.prototype=new ctor;child.__super__=parent.prototype;return child},hasProp={}.hasOwnProperty,indexOf=[].indexOf||function(item){for(var i=0,l=this.length;i<l;i++){if(i in this&&this[i]===item)return i}return-1},slice=[].slice;EventEmitter=require("events").EventEmitter;browser=require("./browser.coffee");GIF=function(superClass){var defaults,frameDefaults;extend(GIF,superClass);defaults={workerScript:"gif.worker.js",workers:2,repeat:0,background:"#fff",quality:10,width:null,height:null,transparent:null,debug:false,dither:false};frameDefaults={delay:500,copy:false};function GIF(options){var base,key,value;this.running=false;this.options={};this.frames=[];this.freeWorkers=[];this.activeWorkers=[];this.setOptions(options);for(key in defaults){value=defaults[key];if((base=this.options)[key]==null){base[key]=value}}}GIF.prototype.setOption=function(key,value){this.options[key]=value;if(this._canvas!=null&&(key==="width"||key==="height")){return this._canvas[key]=value}};GIF.prototype.setOptions=function(options){var key,results,value;results=[];for(key in options){if(!hasProp.call(options,key))continue;value=options[key];results.push(this.setOption(key,value))}return results};GIF.prototype.addFrame=function(image,options){var frame,key;if(options==null){options={}}frame={};frame.transparent=this.options.transparent;for(key in frameDefaults){frame[key]=options[key]||frameDefaults[key]}if(this.options.width==null){this.setOption("width",image.width)}if(this.options.height==null){this.setOption("height",image.height)}if(typeof ImageData!=="undefined"&&ImageData!==null&&image instanceof ImageData){frame.data=image.data}else if(typeof CanvasRenderingContext2D!=="undefined"&&CanvasRenderingContext2D!==null&&image instanceof CanvasRenderingContext2D||typeof WebGLRenderingContext!=="undefined"&&WebGLRenderingContext!==null&&image instanceof WebGLRenderingContext){if(options.copy){frame.data=this.getContextData(image)}else{frame.context=image}}else if(image.childNodes!=null){if(options.copy){frame.data=this.getImageData(image)}else{frame.image=image}}else{throw new Error("Invalid image")}return this.frames.push(frame)};GIF.prototype.render=function(){var i,j,numWorkers,ref;if(this.running){throw new Error("Already running")}if(this.options.width==null||this.options.height==null){throw new Error("Width and height must be set prior to rendering")}this.running=true;this.nextFrame=0;this.finishedFrames=0;this.imageParts=function(){var j,ref,results;results=[];for(i=j=0,ref=this.frames.length;0<=ref?j<ref:j>ref;i=0<=ref?++j:--j){results.push(null)}return results}.call(this);numWorkers=this.spawnWorkers();if(this.options.globalPalette===true){this.renderNextFrame()}else{for(i=j=0,ref=numWorkers;0<=ref?j<ref:j>ref;i=0<=ref?++j:--j){this.renderNextFrame()}}this.emit("start");return this.emit("progress",0)};GIF.prototype.abort=function(){var worker;while(true){worker=this.activeWorkers.shift();if(worker==null){break}this.log("killing active worker");worker.terminate()}this.running=false;return this.emit("abort")};GIF.prototype.spawnWorkers=function(){var j,numWorkers,ref,results;numWorkers=Math.min(this.options.workers,this.frames.length);(function(){results=[];for(var j=ref=this.freeWorkers.length;ref<=numWorkers?j<numWorkers:j>numWorkers;ref<=numWorkers?j++:j--){results.push(j)}return results}).apply(this).forEach(function(_this){return function(i){var worker;_this.log("spawning worker "+i);worker=new Worker(_this.options.workerScript);worker.onmessage=function(event){_this.activeWorkers.splice(_this.activeWorkers.indexOf(worker),1);_this.freeWorkers.push(worker);return _this.frameFinished(event.data)};return _this.freeWorkers.push(worker)}}(this));return numWorkers};GIF.prototype.frameFinished=function(frame){var i,j,ref;this.log("frame "+frame.index+" finished - "+this.activeWorkers.length+" active");this.finishedFrames++;this.emit("progress",this.finishedFrames/this.frames.length);this.imageParts[frame.index]=frame;if(this.options.globalPalette===true){this.options.globalPalette=frame.globalPalette;this.log("global palette analyzed");if(this.frames.length>2){for(i=j=1,ref=this.freeWorkers.length;1<=ref?j<ref:j>ref;i=1<=ref?++j:--j){this.renderNextFrame()}}}if(indexOf.call(this.imageParts,null)>=0){return this.renderNextFrame()}else{return this.finishRendering()}};GIF.prototype.finishRendering=function(){var data,frame,i,image,j,k,l,len,len1,len2,len3,offset,page,ref,ref1,ref2;len=0;ref=this.imageParts;for(j=0,len1=ref.length;j<len1;j++){frame=ref[j];len+=(frame.data.length-1)*frame.pageSize+frame.cursor}len+=frame.pageSize-frame.cursor;this.log("rendering finished - filesize "+Math.round(len/1e3)+"kb");data=new Uint8Array(len);offset=0;ref1=this.imageParts;for(k=0,len2=ref1.length;k<len2;k++){frame=ref1[k];ref2=frame.data;for(i=l=0,len3=ref2.length;l<len3;i=++l){page=ref2[i];data.set(page,offset);if(i===frame.data.length-1){offset+=frame.cursor}else{offset+=frame.pageSize}}}image=new Blob([data],{type:"image/gif"});return this.emit("finished",image,data)};GIF.prototype.renderNextFrame=function(){var frame,task,worker;if(this.freeWorkers.length===0){throw new Error("No free workers")}if(this.nextFrame>=this.frames.length){return}frame=this.frames[this.nextFrame++];worker=this.freeWorkers.shift();task=this.getTask(frame);this.log("starting frame "+(task.index+1)+" of "+this.frames.length);this.activeWorkers.push(worker);return worker.postMessage(task)};GIF.prototype.getContextData=function(ctx){return ctx.getImageData(0,0,this.options.width,this.options.height).data};GIF.prototype.getImageData=function(image){var ctx;if(this._canvas==null){this._canvas=document.createElement("canvas");this._canvas.width=this.options.width;this._canvas.height=this.options.height}ctx=this._canvas.getContext("2d");ctx.setFill=this.options.background;ctx.fillRect(0,0,this.options.width,this.options.height);ctx.drawImage(image,0,0);return this.getContextData(ctx)};GIF.prototype.getTask=function(frame){var index,task;index=this.frames.indexOf(frame);task={index:index,last:index===this.frames.length-1,delay:frame.delay,transparent:frame.transparent,width:this.options.width,height:this.options.height,quality:this.options.quality,dither:this.options.dither,globalPalette:this.options.globalPalette,repeat:this.options.repeat,canTransfer:browser.name==="chrome"};if(frame.data!=null){task.data=frame.data}else if(frame.context!=null){task.data=this.getContextData(frame.context)}else if(frame.image!=null){task.data=this.getImageData(frame.image)}else{throw new Error("Invalid frame")}return task};GIF.prototype.log=function(){var args;args=1<=arguments.length?slice.call(arguments,0):[];if(!this.options.debug){return}return console.log.apply(console,args)};return GIF}(EventEmitter);module.exports=GIF},{"./browser.coffee":2,events:1}]},{},[3])(3)}); +//# sourceMappingURL=gif.js.map diff --git a/static/js/gif.worker.js b/static/js/gif.worker.js new file mode 100644 index 0000000..269624e --- /dev/null +++ b/static/js/gif.worker.js @@ -0,0 +1,3 @@ +// gif.worker.js 0.2.0 - https://github.com/jnordberg/gif.js +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){var NeuQuant=require("./TypedNeuQuant.js");var LZWEncoder=require("./LZWEncoder.js");function ByteArray(){this.page=-1;this.pages=[];this.newPage()}ByteArray.pageSize=4096;ByteArray.charMap={};for(var i=0;i<256;i++)ByteArray.charMap[i]=String.fromCharCode(i);ByteArray.prototype.newPage=function(){this.pages[++this.page]=new Uint8Array(ByteArray.pageSize);this.cursor=0};ByteArray.prototype.getData=function(){var rv="";for(var p=0;p<this.pages.length;p++){for(var i=0;i<ByteArray.pageSize;i++){rv+=ByteArray.charMap[this.pages[p][i]]}}return rv};ByteArray.prototype.writeByte=function(val){if(this.cursor>=ByteArray.pageSize)this.newPage();this.pages[this.page][this.cursor++]=val};ByteArray.prototype.writeUTFBytes=function(string){for(var l=string.length,i=0;i<l;i++)this.writeByte(string.charCodeAt(i))};ByteArray.prototype.writeBytes=function(array,offset,length){for(var l=length||array.length,i=offset||0;i<l;i++)this.writeByte(array[i])};function GIFEncoder(width,height){this.width=~~width;this.height=~~height;this.transparent=null;this.transIndex=0;this.repeat=-1;this.delay=0;this.image=null;this.pixels=null;this.indexedPixels=null;this.colorDepth=null;this.colorTab=null;this.neuQuant=null;this.usedEntry=new Array;this.palSize=7;this.dispose=-1;this.firstFrame=true;this.sample=10;this.dither=false;this.globalPalette=false;this.out=new ByteArray}GIFEncoder.prototype.setDelay=function(milliseconds){this.delay=Math.round(milliseconds/10)};GIFEncoder.prototype.setFrameRate=function(fps){this.delay=Math.round(100/fps)};GIFEncoder.prototype.setDispose=function(disposalCode){if(disposalCode>=0)this.dispose=disposalCode};GIFEncoder.prototype.setRepeat=function(repeat){this.repeat=repeat};GIFEncoder.prototype.setTransparent=function(color){this.transparent=color};GIFEncoder.prototype.addFrame=function(imageData){this.image=imageData;this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null;this.getImagePixels();this.analyzePixels();if(this.globalPalette===true)this.globalPalette=this.colorTab;if(this.firstFrame){this.writeLSD();this.writePalette();if(this.repeat>=0){this.writeNetscapeExt()}}this.writeGraphicCtrlExt();this.writeImageDesc();if(!this.firstFrame&&!this.globalPalette)this.writePalette();this.writePixels();this.firstFrame=false};GIFEncoder.prototype.finish=function(){this.out.writeByte(59)};GIFEncoder.prototype.setQuality=function(quality){if(quality<1)quality=1;this.sample=quality};GIFEncoder.prototype.setDither=function(dither){if(dither===true)dither="FloydSteinberg";this.dither=dither};GIFEncoder.prototype.setGlobalPalette=function(palette){this.globalPalette=palette};GIFEncoder.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette};GIFEncoder.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")};GIFEncoder.prototype.analyzePixels=function(){if(!this.colorTab){this.neuQuant=new NeuQuant(this.pixels,this.sample);this.neuQuant.buildColormap();this.colorTab=this.neuQuant.getColormap()}if(this.dither){this.ditherPixels(this.dither.replace("-serpentine",""),this.dither.match(/-serpentine/)!==null)}else{this.indexPixels()}this.pixels=null;this.colorDepth=8;this.palSize=7;if(this.transparent!==null){this.transIndex=this.findClosest(this.transparent,true)}};GIFEncoder.prototype.indexPixels=function(imgq){var nPix=this.pixels.length/3;this.indexedPixels=new Uint8Array(nPix);var k=0;for(var j=0;j<nPix;j++){var index=this.findClosestRGB(this.pixels[k++]&255,this.pixels[k++]&255,this.pixels[k++]&255);this.usedEntry[index]=true;this.indexedPixels[j]=index}};GIFEncoder.prototype.ditherPixels=function(kernel,serpentine){var kernels={FalseFloydSteinberg:[[3/8,1,0],[3/8,0,1],[2/8,1,1]],FloydSteinberg:[[7/16,1,0],[3/16,-1,1],[5/16,0,1],[1/16,1,1]],Stucki:[[8/42,1,0],[4/42,2,0],[2/42,-2,1],[4/42,-1,1],[8/42,0,1],[4/42,1,1],[2/42,2,1],[1/42,-2,2],[2/42,-1,2],[4/42,0,2],[2/42,1,2],[1/42,2,2]],Atkinson:[[1/8,1,0],[1/8,2,0],[1/8,-1,1],[1/8,0,1],[1/8,1,1],[1/8,0,2]]};if(!kernel||!kernels[kernel]){throw"Unknown dithering kernel: "+kernel}var ds=kernels[kernel];var index=0,height=this.height,width=this.width,data=this.pixels;var direction=serpentine?-1:1;this.indexedPixels=new Uint8Array(this.pixels.length/3);for(var y=0;y<height;y++){if(serpentine)direction=direction*-1;for(var x=direction==1?0:width-1,xend=direction==1?width:0;x!==xend;x+=direction){index=y*width+x;var idx=index*3;var r1=data[idx];var g1=data[idx+1];var b1=data[idx+2];idx=this.findClosestRGB(r1,g1,b1);this.usedEntry[idx]=true;this.indexedPixels[index]=idx;idx*=3;var r2=this.colorTab[idx];var g2=this.colorTab[idx+1];var b2=this.colorTab[idx+2];var er=r1-r2;var eg=g1-g2;var eb=b1-b2;for(var i=direction==1?0:ds.length-1,end=direction==1?ds.length:0;i!==end;i+=direction){var x1=ds[i][1];var y1=ds[i][2];if(x1+x>=0&&x1+x<width&&y1+y>=0&&y1+y<height){var d=ds[i][0];idx=index+x1+y1*width;idx*=3;data[idx]=Math.max(0,Math.min(255,data[idx]+er*d));data[idx+1]=Math.max(0,Math.min(255,data[idx+1]+eg*d));data[idx+2]=Math.max(0,Math.min(255,data[idx+2]+eb*d))}}}}};GIFEncoder.prototype.findClosest=function(c,used){return this.findClosestRGB((c&16711680)>>16,(c&65280)>>8,c&255,used)};GIFEncoder.prototype.findClosestRGB=function(r,g,b,used){if(this.colorTab===null)return-1;if(this.neuQuant&&!used){return this.neuQuant.lookupRGB(r,g,b)}var c=b|g<<8|r<<16;var minpos=0;var dmin=256*256*256;var len=this.colorTab.length;for(var i=0,index=0;i<len;index++){var dr=r-(this.colorTab[i++]&255);var dg=g-(this.colorTab[i++]&255);var db=b-(this.colorTab[i++]&255);var d=dr*dr+dg*dg+db*db;if((!used||this.usedEntry[index])&&d<dmin){dmin=d;minpos=index}}return minpos};GIFEncoder.prototype.getImagePixels=function(){var w=this.width;var h=this.height;this.pixels=new Uint8Array(w*h*3);var data=this.image;var srcPos=0;var count=0;for(var i=0;i<h;i++){for(var j=0;j<w;j++){this.pixels[count++]=data[srcPos++];this.pixels[count++]=data[srcPos++];this.pixels[count++]=data[srcPos++];srcPos++}}};GIFEncoder.prototype.writeGraphicCtrlExt=function(){this.out.writeByte(33);this.out.writeByte(249);this.out.writeByte(4);var transp,disp;if(this.transparent===null){transp=0;disp=0}else{transp=1;disp=2}if(this.dispose>=0){disp=dispose&7}disp<<=2;this.out.writeByte(0|disp|0|transp);this.writeShort(this.delay);this.out.writeByte(this.transIndex);this.out.writeByte(0)};GIFEncoder.prototype.writeImageDesc=function(){this.out.writeByte(44);this.writeShort(0);this.writeShort(0);this.writeShort(this.width);this.writeShort(this.height);if(this.firstFrame||this.globalPalette){this.out.writeByte(0)}else{this.out.writeByte(128|0|0|0|this.palSize)}};GIFEncoder.prototype.writeLSD=function(){this.writeShort(this.width);this.writeShort(this.height);this.out.writeByte(128|112|0|this.palSize);this.out.writeByte(0);this.out.writeByte(0)};GIFEncoder.prototype.writeNetscapeExt=function(){this.out.writeByte(33);this.out.writeByte(255);this.out.writeByte(11);this.out.writeUTFBytes("NETSCAPE2.0");this.out.writeByte(3);this.out.writeByte(1);this.writeShort(this.repeat);this.out.writeByte(0)};GIFEncoder.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);var n=3*256-this.colorTab.length;for(var i=0;i<n;i++)this.out.writeByte(0)};GIFEncoder.prototype.writeShort=function(pValue){this.out.writeByte(pValue&255);this.out.writeByte(pValue>>8&255)};GIFEncoder.prototype.writePixels=function(){var enc=new LZWEncoder(this.width,this.height,this.indexedPixels,this.colorDepth);enc.encode(this.out)};GIFEncoder.prototype.stream=function(){return this.out};module.exports=GIFEncoder},{"./LZWEncoder.js":2,"./TypedNeuQuant.js":3}],2:[function(require,module,exports){var EOF=-1;var BITS=12;var HSIZE=5003;var masks=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function LZWEncoder(width,height,pixels,colorDepth){var initCodeSize=Math.max(2,colorDepth);var accum=new Uint8Array(256);var htab=new Int32Array(HSIZE);var codetab=new Int32Array(HSIZE);var cur_accum,cur_bits=0;var a_count;var free_ent=0;var maxcode;var clear_flg=false;var g_init_bits,ClearCode,EOFCode;function char_out(c,outs){accum[a_count++]=c;if(a_count>=254)flush_char(outs)}function cl_block(outs){cl_hash(HSIZE);free_ent=ClearCode+2;clear_flg=true;output(ClearCode,outs)}function cl_hash(hsize){for(var i=0;i<hsize;++i)htab[i]=-1}function compress(init_bits,outs){var fcode,c,i,ent,disp,hsize_reg,hshift;g_init_bits=init_bits;clear_flg=false;n_bits=g_init_bits;maxcode=MAXCODE(n_bits);ClearCode=1<<init_bits-1;EOFCode=ClearCode+1;free_ent=ClearCode+2;a_count=0;ent=nextPixel();hshift=0;for(fcode=HSIZE;fcode<65536;fcode*=2)++hshift;hshift=8-hshift;hsize_reg=HSIZE;cl_hash(hsize_reg);output(ClearCode,outs);outer_loop:while((c=nextPixel())!=EOF){fcode=(c<<BITS)+ent;i=c<<hshift^ent;if(htab[i]===fcode){ent=codetab[i];continue}else if(htab[i]>=0){disp=hsize_reg-i;if(i===0)disp=1;do{if((i-=disp)<0)i+=hsize_reg;if(htab[i]===fcode){ent=codetab[i];continue outer_loop}}while(htab[i]>=0)}output(ent,outs);ent=c;if(free_ent<1<<BITS){codetab[i]=free_ent++;htab[i]=fcode}else{cl_block(outs)}}output(ent,outs);output(EOFCode,outs)}function encode(outs){outs.writeByte(initCodeSize);remaining=width*height;curPixel=0;compress(initCodeSize+1,outs);outs.writeByte(0)}function flush_char(outs){if(a_count>0){outs.writeByte(a_count);outs.writeBytes(accum,0,a_count);a_count=0}}function MAXCODE(n_bits){return(1<<n_bits)-1}function nextPixel(){if(remaining===0)return EOF;--remaining;var pix=pixels[curPixel++];return pix&255}function output(code,outs){cur_accum&=masks[cur_bits];if(cur_bits>0)cur_accum|=code<<cur_bits;else cur_accum=code;cur_bits+=n_bits;while(cur_bits>=8){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}if(free_ent>maxcode||clear_flg){if(clear_flg){maxcode=MAXCODE(n_bits=g_init_bits);clear_flg=false}else{++n_bits;if(n_bits==BITS)maxcode=1<<BITS;else maxcode=MAXCODE(n_bits)}}if(code==EOFCode){while(cur_bits>0){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}flush_char(outs)}}this.encode=encode}module.exports=LZWEncoder},{}],3:[function(require,module,exports){var ncycles=100;var netsize=256;var maxnetpos=netsize-1;var netbiasshift=4;var intbiasshift=16;var intbias=1<<intbiasshift;var gammashift=10;var gamma=1<<gammashift;var betashift=10;var beta=intbias>>betashift;var betagamma=intbias<<gammashift-betashift;var initrad=netsize>>3;var radiusbiasshift=6;var radiusbias=1<<radiusbiasshift;var initradius=initrad*radiusbias;var radiusdec=30;var alphabiasshift=10;var initalpha=1<<alphabiasshift;var alphadec;var radbiasshift=8;var radbias=1<<radbiasshift;var alpharadbshift=alphabiasshift+radbiasshift;var alpharadbias=1<<alpharadbshift;var prime1=499;var prime2=491;var prime3=487;var prime4=503;var minpicturebytes=3*prime4;function NeuQuant(pixels,samplefac){var network;var netindex;var bias;var freq;var radpower;function init(){network=[];netindex=new Int32Array(256);bias=new Int32Array(netsize);freq=new Int32Array(netsize);radpower=new Int32Array(netsize>>3);var i,v;for(i=0;i<netsize;i++){v=(i<<netbiasshift+8)/netsize;network[i]=new Float64Array([v,v,v,0]);freq[i]=intbias/netsize;bias[i]=0}}function unbiasnet(){for(var i=0;i<netsize;i++){network[i][0]>>=netbiasshift;network[i][1]>>=netbiasshift;network[i][2]>>=netbiasshift;network[i][3]=i}}function altersingle(alpha,i,b,g,r){network[i][0]-=alpha*(network[i][0]-b)/initalpha;network[i][1]-=alpha*(network[i][1]-g)/initalpha;network[i][2]-=alpha*(network[i][2]-r)/initalpha}function alterneigh(radius,i,b,g,r){var lo=Math.abs(i-radius);var hi=Math.min(i+radius,netsize);var j=i+1;var k=i-1;var m=1;var p,a;while(j<hi||k>lo){a=radpower[m++];if(j<hi){p=network[j++];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}if(k>lo){p=network[k--];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}}}function contest(b,g,r){var bestd=~(1<<31);var bestbiasd=bestd;var bestpos=-1;var bestbiaspos=bestpos;var i,n,dist,biasdist,betafreq;for(i=0;i<netsize;i++){n=network[i];dist=Math.abs(n[0]-b)+Math.abs(n[1]-g)+Math.abs(n[2]-r);if(dist<bestd){bestd=dist;bestpos=i}biasdist=dist-(bias[i]>>intbiasshift-netbiasshift);if(biasdist<bestbiasd){bestbiasd=biasdist;bestbiaspos=i}betafreq=freq[i]>>betashift;freq[i]-=betafreq;bias[i]+=betafreq<<gammashift}freq[bestpos]+=beta;bias[bestpos]-=betagamma;return bestbiaspos}function inxbuild(){var i,j,p,q,smallpos,smallval,previouscol=0,startpos=0;for(i=0;i<netsize;i++){p=network[i];smallpos=i;smallval=p[1];for(j=i+1;j<netsize;j++){q=network[j];if(q[1]<smallval){smallpos=j;smallval=q[1]}}q=network[smallpos];if(i!=smallpos){j=q[0];q[0]=p[0];p[0]=j;j=q[1];q[1]=p[1];p[1]=j;j=q[2];q[2]=p[2];p[2]=j;j=q[3];q[3]=p[3];p[3]=j}if(smallval!=previouscol){netindex[previouscol]=startpos+i>>1;for(j=previouscol+1;j<smallval;j++)netindex[j]=i;previouscol=smallval;startpos=i}}netindex[previouscol]=startpos+maxnetpos>>1;for(j=previouscol+1;j<256;j++)netindex[j]=maxnetpos}function inxsearch(b,g,r){var a,p,dist;var bestd=1e3;var best=-1;var i=netindex[g];var j=i-1;while(i<netsize||j>=0){if(i<netsize){p=network[i];dist=p[1]-g;if(dist>=bestd)i=netsize;else{i++;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist<bestd){a=p[2]-r;if(a<0)a=-a;dist+=a;if(dist<bestd){bestd=dist;best=p[3]}}}}if(j>=0){p=network[j];dist=g-p[1];if(dist>=bestd)j=-1;else{j--;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist<bestd){a=p[2]-r;if(a<0)a=-a;dist+=a;if(dist<bestd){bestd=dist;best=p[3]}}}}}return best}function learn(){var i;var lengthcount=pixels.length;var alphadec=30+(samplefac-1)/3;var samplepixels=lengthcount/(3*samplefac);var delta=~~(samplepixels/ncycles);var alpha=initalpha;var radius=initradius;var rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(i=0;i<rad;i++)radpower[i]=alpha*((rad*rad-i*i)*radbias/(rad*rad));var step;if(lengthcount<minpicturebytes){samplefac=1;step=3}else if(lengthcount%prime1!==0){step=3*prime1}else if(lengthcount%prime2!==0){step=3*prime2}else if(lengthcount%prime3!==0){step=3*prime3}else{step=3*prime4}var b,g,r,j;var pix=0;i=0;while(i<samplepixels){b=(pixels[pix]&255)<<netbiasshift;g=(pixels[pix+1]&255)<<netbiasshift;r=(pixels[pix+2]&255)<<netbiasshift;j=contest(b,g,r);altersingle(alpha,j,b,g,r);if(rad!==0)alterneigh(rad,j,b,g,r);pix+=step;if(pix>=lengthcount)pix-=lengthcount;i++;if(delta===0)delta=1;if(i%delta===0){alpha-=alpha/alphadec;radius-=radius/radiusdec;rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(j=0;j<rad;j++)radpower[j]=alpha*((rad*rad-j*j)*radbias/(rad*rad))}}}function buildColormap(){init();learn();unbiasnet();inxbuild()}this.buildColormap=buildColormap;function getColormap(){var map=[];var index=[];for(var i=0;i<netsize;i++)index[network[i][3]]=i;var k=0;for(var l=0;l<netsize;l++){var j=index[l];map[k++]=network[j][0];map[k++]=network[j][1];map[k++]=network[j][2]}return map}this.getColormap=getColormap;this.lookupRGB=inxsearch}module.exports=NeuQuant},{}],4:[function(require,module,exports){var GIFEncoder,renderFrame;GIFEncoder=require("./GIFEncoder.js");renderFrame=function(frame){var encoder,page,stream,transfer;encoder=new GIFEncoder(frame.width,frame.height);if(frame.index===0){encoder.writeHeader()}else{encoder.firstFrame=false}encoder.setTransparent(frame.transparent);encoder.setRepeat(frame.repeat);encoder.setDelay(frame.delay);encoder.setQuality(frame.quality);encoder.setDither(frame.dither);encoder.setGlobalPalette(frame.globalPalette);encoder.addFrame(frame.data);if(frame.last){encoder.finish()}if(frame.globalPalette===true){frame.globalPalette=encoder.getGlobalPalette()}stream=encoder.stream();frame.data=stream.pages;frame.cursor=stream.cursor;frame.pageSize=stream.constructor.pageSize;if(frame.canTransfer){transfer=function(){var i,len,ref,results;ref=frame.data;results=[];for(i=0,len=ref.length;i<len;i++){page=ref[i];results.push(page.buffer)}return results}();return self.postMessage(frame,transfer)}else{return self.postMessage(frame)}};self.onmessage=function(event){return renderFrame(event.data)}},{"./GIFEncoder.js":1}]},{},[4]); +//# sourceMappingURL=gif.worker.js.map diff --git a/static/lastfm-week.sh b/static/lastfm-week.sh new file mode 100755 index 0000000..1d64688 --- /dev/null +++ b/static/lastfm-week.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Last.fm Weekly Stats Script +# Fetches your Last.fm listening statistics for the past week +# +# Requirements: +# - curl (for API requests) +# - jq (for JSON parsing) +# +# Usage: ./lastfm-week.sh +# +# Setup: +# Create a .env file with: +# LASTFM_API_KEY=your_api_key_here +# LASTFM_USERNAME=your_username_here +# +# Output: Markdown-formatted stats with top artists and track counts +# +# Download from: https://ritual.sh/resources/lastfm-stats/ + +# Load environment variables from .env file +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +else + echo "Error: .env file not found" + exit 1 +fi + +# Check required variables +if [ -z "$LASTFM_API_KEY" ] || [ -z "$LASTFM_USERNAME" ]; then + echo "Error: LASTFM_API_KEY and LASTFM_USERNAME must be set in .env file" + exit 1 +fi + +API_BASE="http://ws.audioscrobbler.com/2.0/" + +# Get current timestamp +NOW=$(date +%s) +# Get timestamp from 7 days ago +WEEK_AGO=$((NOW - 604800)) + +# Fetch top artists for the week +TOP_ARTISTS=$(curl -s "${API_BASE}?method=user.gettopartists&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&period=7day&limit=5") + +# Fetch recent tracks to count this week's scrobbles +RECENT_TRACKS=$(curl -s "${API_BASE}?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&from=${WEEK_AGO}&to=${NOW}&limit=1") + +# Get total track count +TOTAL_TRACKS=$(echo "$RECENT_TRACKS" | jq -r '.recenttracks["@attr"].total') + +# Output in markdown format +echo "## Last.fm Weekly Stats" +echo "" +echo "**Total Tracks:** ${TOTAL_TRACKS}" +echo "" +echo "**Top 5 Artists:**" +echo "" + +# Parse and display top 5 artists as markdown links +echo "$TOP_ARTISTS" | jq -r '.topartists.artist[] | "- [\(.name)](\(.url)) - \(.playcount) plays"' \ No newline at end of file diff --git a/static/lavalamp-demo.html b/static/lavalamp-demo.html new file mode 100644 index 0000000..9da0cdb --- /dev/null +++ b/static/lavalamp-demo.html @@ -0,0 +1,371 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Lava Lamp Adoptable - Demo Examples + + + +
+

🌋 Lava Lamp Adoptable Demo

+

+ See the lava lamp in action at various sizes and color schemes! +

+ +
+ +
+
Classic Orange
+
+ +
+
150px × 280px
+
+ + +
+
Purple Dreams
+
+ +
+
120px × 250px
+
+ + +
+
Ocean Blue
+
+ +
+
100px × 200px
+
+ + +
+
Lava Red
+
+ +
+
180px × 320px
+
+ + +
+
Mint Fresh
+
+ +
+
140px × 260px
+
+ + +
+
Sunset Glow
+
+ +
+
110px × 220px
+
+ + +
+
Cyber Green (Pixelated)
+
+ +
+
160px × 300px (6px pixels)
+
+ + +
+
Pink Bubble (Pixelated)
+
+ +
+
130px × 240px (3px pixels)
+
+ + +
+
Tiny Retro (Pixelated)
+
+ +
+
40px × 80px (3px pixels)
+
+
+ +
+

How to Use

+

+ Simply copy and paste this code into your website. The lava lamp will + scale to fit any container size you specify! +

+
<div style="width: 200px; height: 350px;">
+  <script src="https://ritual.sh/js/adoptables/lavalamp.js"
+          data-bg-color-1="#F8E45C"
+          data-bg-color-2="#FF7800"
+          data-blob-color-1="#FF4500"
+          data-blob-color-2="#FF6347"
+          data-case-color="#333333"
+          data-blob-count="6"
+          data-speed="1.0"
+          data-blob-size="1.0"></script>
+</div>
+ +

Customization Options

+ + +

+ 🌟 Create your own custom lava lamp at + ritual.sh/resources/adoptables +

+
+
+ + diff --git a/static/publickey.asc b/static/publickey.asc new file mode 100644 index 0000000..2f54b8a --- /dev/null +++ b/static/publickey.asc @@ -0,0 +1,17 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xjMEaVzgdxYJKwYBBAHaRw8BAQdADvLcBAhz3La0tovwPlJ2Z5uKHufb9MS9 +6CGKBIgssOfNHWRhbkByaXR1YWwuc2ggPGRhbkByaXR1YWwuc2g+wsARBBMW +CgCDBYJpXOB3AwsJBwkQhk08tBmPAUJFFAAAAAAAHAAgc2FsdEBub3RhdGlv +bnMub3BlbnBncGpzLm9yZ2lm/k+MdxoNH3+THDnkaLgtXLiV1AuTGyJ0ZlAe +4ngSAxUKCAQWAAIBAhkBApsDAh4BFiEEFv8cu2aJ5iyvXhJ0hk08tBmPAUIA +ABB0AP0ZvUva/yf6ZR8T7Zwp8+RtqyYfiDlJbpqt3hEGALw0EgD6AxO3O4tm +f2IguB2kuUfrH223xGEzIOHYz1Ciwt6haAPOOARpXOB3EgorBgEEAZdVAQUB +AQdAjllaNe/Z1No5rRxVz6SBsSN4o2xDHPWb0PnGxZAsT3EDAQgHwr4EGBYK +AHAFgmlc4HcJEIZNPLQZjwFCRRQAAAAAABwAIHNhbHRAbm90YXRpb25zLm9w +ZW5wZ3Bqcy5vcmfxjGz++fxlZxuZLgSTOTjWRrtwvPuLEFJSN1VxonqqcQKb +DBYhBBb/HLtmieYsr14SdIZNPLQZjwFCAABdiAD8Cz5dxfHSm1mBwQ0jKjZd +sktUeaa3Ksw2NsMFU3sbnoIBAMBoAaQVq+q5RoLn1ZOT/DIeDU+1o5HVAL0k +sb/b4NYN +=EIiw +-----END PGP PUBLIC KEY BLOCK-----