Merge branch 'main' into develop
This commit is contained in:
commit
af3335a837
113 changed files with 14843 additions and 602 deletions
1
.webmentions-sent
Normal file
1
.webmentions-sent
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
https://ritual.sh/blog/2026-01-06-week-3-bit-chilly-out/|https://enclose.horse/
|
||||||
262
GRAPHS.md
Normal file
262
GRAPHS.md
Normal file
|
|
@ -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
|
||||||
|
<div class="graph-container compact">
|
||||||
|
{{< graph ... >}}
|
||||||
|
...
|
||||||
|
{{< /graph >}}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 `<head>`)
|
||||||
|
- 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
|
||||||
386
assets/js/adoptables/lavalamp-adoptable.js
Normal file
386
assets/js/adoptables/lavalamp-adoptable.js
Normal file
|
|
@ -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 = `
|
||||||
|
<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>
|
||||||
|
<filter id="${pixelateFilterId}" x="0%" y="0%" width="100%" height="100%">
|
||||||
|
<feFlood x="0" y="0" height="1" width="1"/>
|
||||||
|
<feComposite width="4" height="4" class="pixelate-composite"/>
|
||||||
|
<feTile result="a"/>
|
||||||
|
<feComposite in="SourceGraphic" in2="a" operator="in"/>
|
||||||
|
<feMorphology operator="dilate" radius="2" class="pixelate-morphology"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `<script src="${siteUrl}/js/adoptables/lavalamp.js"
|
||||||
|
data-bg-color-1="${bgColor1Input.value}"
|
||||||
|
data-bg-color-2="${bgColor2Input.value}"
|
||||||
|
data-blob-color-1="${blobColor1Input.value}"
|
||||||
|
data-blob-color-2="${blobColor2Input.value}"
|
||||||
|
data-case-color="${caseColorInput.value}"
|
||||||
|
data-blob-count="${blobCountInput.value}"
|
||||||
|
data-speed="${speedInput.value}"
|
||||||
|
data-blob-size="${blobSizeInput.value}"${pixelateAttrs}><\/script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
bgColor1Input.addEventListener("input", updatePreview);
|
||||||
|
bgColor2Input.addEventListener("input", updatePreview);
|
||||||
|
blobColor1Input.addEventListener("input", updatePreview);
|
||||||
|
blobColor2Input.addEventListener("input", updatePreview);
|
||||||
|
caseColorInput.addEventListener("input", updatePreview);
|
||||||
|
|
||||||
|
blobCountInput.addEventListener("input", function () {
|
||||||
|
blobCountValue.textContent = this.value;
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
speedInput.addEventListener("input", function () {
|
||||||
|
speedValue.textContent = parseFloat(this.value).toFixed(1);
|
||||||
|
// Speed changes require recreating blobs for all instances
|
||||||
|
Object.keys(lampInstances).forEach((key) => {
|
||||||
|
const instance = lampInstances[key];
|
||||||
|
instance.blobs.forEach((blob) =>
|
||||||
|
instance.lampElements.blobsContainer.removeChild(blob),
|
||||||
|
);
|
||||||
|
instance.blobs = [];
|
||||||
|
});
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
blobSizeInput.addEventListener("input", function () {
|
||||||
|
blobSizeValue.textContent = parseFloat(this.value).toFixed(1);
|
||||||
|
// Size changes require recreating blobs for all instances
|
||||||
|
Object.keys(lampInstances).forEach((key) => {
|
||||||
|
const instance = lampInstances[key];
|
||||||
|
instance.blobs.forEach((blob) =>
|
||||||
|
instance.lampElements.blobsContainer.removeChild(blob),
|
||||||
|
);
|
||||||
|
instance.blobs = [];
|
||||||
|
});
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
pixelateInput.addEventListener("change", function () {
|
||||||
|
// Show/hide pixel size slider
|
||||||
|
if (this.checked) {
|
||||||
|
pixelSizeGroup.style.display = "block";
|
||||||
|
} else {
|
||||||
|
pixelSizeGroup.style.display = "none";
|
||||||
|
}
|
||||||
|
updatePixelation();
|
||||||
|
});
|
||||||
|
|
||||||
|
pixelSizeInput.addEventListener("input", function () {
|
||||||
|
pixelSizeValue.textContent = this.value;
|
||||||
|
updatePixelation();
|
||||||
|
});
|
||||||
|
|
||||||
|
getCodeBtn.addEventListener("click", function () {
|
||||||
|
embedCodeDisplay.textContent = generateEmbedCode();
|
||||||
|
embedCodeSection.style.display = "block";
|
||||||
|
embedCodeSection.scrollIntoView({ behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
initPreview();
|
||||||
|
})();
|
||||||
70
assets/js/code-copy.js
Normal file
70
assets/js/code-copy.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Add copy buttons to all code blocks
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// Find all <pre> elements that contain <code>
|
||||||
|
const codeBlocks = document.querySelectorAll("pre code");
|
||||||
|
|
||||||
|
codeBlocks.forEach((codeBlock) => {
|
||||||
|
const pre = codeBlock.parentElement;
|
||||||
|
|
||||||
|
// Create wrapper for positioning
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.style.position = "relative";
|
||||||
|
|
||||||
|
// Wrap the pre element
|
||||||
|
pre.parentNode.insertBefore(wrapper, pre);
|
||||||
|
wrapper.appendChild(pre);
|
||||||
|
|
||||||
|
// Create copy button
|
||||||
|
const copyButton = document.createElement("button");
|
||||||
|
copyButton.className = "code-copy-btn";
|
||||||
|
copyButton.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
copyButton.setAttribute("aria-label", "Copy code to clipboard");
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
copyButton.addEventListener("click", async () => {
|
||||||
|
const code = codeBlock.textContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
copyButton.classList.add("copied");
|
||||||
|
|
||||||
|
// Reset after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButton.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy code:", err);
|
||||||
|
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = code;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
copyButton.classList.add("copied");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButton.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err2) {
|
||||||
|
console.error("Fallback copy failed:", err2);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(copyButton);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
assets/js/crt-logs.js
Normal file
110
assets/js/crt-logs.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// CRT Log Screen with Analytics
|
||||||
|
// This script updates the CRT screen with a mix of fake logs and real visitor stats
|
||||||
|
|
||||||
|
function initCRTLogs() {
|
||||||
|
const crtScreen = document.getElementById('crt-logs');
|
||||||
|
|
||||||
|
if (!crtScreen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeLogs = [
|
||||||
|
'[WARN] High load detected - time for coffee break',
|
||||||
|
'[ERROR] 404: Motivation not found',
|
||||||
|
'[WARN] Firewall detected actual fire.',
|
||||||
|
'[ERROR] Keyboard not found. Press F1 to continue.',
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCRTScreen(stats) {
|
||||||
|
const logs = [];
|
||||||
|
|
||||||
|
// Add initial command
|
||||||
|
logs.push('> tail -f /var/log');
|
||||||
|
|
||||||
|
// Mix fake logs with real stats
|
||||||
|
const totalLogs = 10;
|
||||||
|
const statsPositions = [3, 6, 9]; // Insert stats at these positions
|
||||||
|
|
||||||
|
let fakeLogIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalLogs; i++) {
|
||||||
|
if (statsPositions.includes(i) && stats) {
|
||||||
|
// Insert real stats
|
||||||
|
if (i === 3) {
|
||||||
|
logs.push(`[STATS] Total visitors: ${stats.totalHits.toLocaleString()}`);
|
||||||
|
} else if (i === 6) {
|
||||||
|
logs.push(`[STATS] Unique visitors: ${stats.uniqueVisitors.toLocaleString()}`);
|
||||||
|
} else if (i === 9 && stats.lastUpdated) {
|
||||||
|
logs.push(`[STATS] Last updated: ${formatDate(stats.lastUpdated)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert fake log
|
||||||
|
logs.push(fakeLogs[fakeLogIndex % fakeLogs.length]);
|
||||||
|
fakeLogIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate logs appearing one by one
|
||||||
|
crtScreen.innerHTML = '> tail -f /var/log<br>\n<span class="cursor-blink">_</span>';
|
||||||
|
|
||||||
|
let currentIndex = 0;
|
||||||
|
const lineDelay = 150; // milliseconds between each line
|
||||||
|
|
||||||
|
function addNextLine() {
|
||||||
|
if (currentIndex < logs.length - 1) { // -1 to skip the initial command we already added
|
||||||
|
const displayedLogs = logs.slice(1, currentIndex + 2); // Skip initial command, add lines progressively
|
||||||
|
crtScreen.innerHTML = logs[0] + '<br>\n' + displayedLogs.join('<br>\n') + '<br>\n<span class="cursor-blink">_</span>';
|
||||||
|
currentIndex++;
|
||||||
|
setTimeout(addNextLine, lineDelay);
|
||||||
|
} else {
|
||||||
|
// Final update with cursor
|
||||||
|
crtScreen.innerHTML = logs.join('<br>\n') + '<br>\n<span class="cursor-blink">_</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(addNextLine, lineDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAnalyticsStats() {
|
||||||
|
fetch('https://api.ritual.sh/analytics/stats')
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch stats');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(stats => {
|
||||||
|
updateCRTScreen(stats);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
// Silently fail and show fake logs only
|
||||||
|
console.debug('Failed to load analytics stats:', err);
|
||||||
|
updateCRTScreen(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchAnalyticsStats();
|
||||||
|
|
||||||
|
// Refresh stats every 30 seconds
|
||||||
|
setInterval(fetchAnalyticsStats, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initCRTLogs);
|
||||||
|
} else {
|
||||||
|
// DOM already loaded
|
||||||
|
initCRTLogs();
|
||||||
|
}
|
||||||
371
assets/js/guestbook.js
Normal file
371
assets/js/guestbook.js
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
/**
|
||||||
|
* Guestbook functionality for ritual.sh
|
||||||
|
* Custom implementation that calls the guestbook API directly
|
||||||
|
*/
|
||||||
|
|
||||||
|
class GuestbookManager {
|
||||||
|
constructor() {
|
||||||
|
// Configuration - Update this URL when the backend is deployed
|
||||||
|
this.apiUrl = "https://guestbook.ritual.sh";
|
||||||
|
this.perPage = 20;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.totalPages = 1;
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
this.form = document.getElementById("guestbook-form");
|
||||||
|
this.entriesList = document.getElementById("entries-list");
|
||||||
|
this.entriesLoading = document.getElementById("entries-loading");
|
||||||
|
this.entriesError = document.getElementById("entries-error");
|
||||||
|
this.pagination = document.getElementById("pagination");
|
||||||
|
this.formFeedback = document.getElementById("form-feedback");
|
||||||
|
this.submitBtn = document.getElementById("submit-btn");
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!this.form) return;
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
this.form.addEventListener("submit", (e) => this.handleSubmit(e));
|
||||||
|
|
||||||
|
// Load initial entries
|
||||||
|
this.loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load guestbook entries from the API
|
||||||
|
*/
|
||||||
|
async loadEntries(page = 1) {
|
||||||
|
this.currentPage = page;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.apiUrl}/entries?page=${page}&per_page=${this.perPage}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Handle the actual API response structure
|
||||||
|
if (data.entries !== undefined) {
|
||||||
|
// API returns entries directly
|
||||||
|
this.renderEntries(data.entries || []);
|
||||||
|
this.totalPages = data.total_pages || 1;
|
||||||
|
|
||||||
|
// Create pagination object from the flat response
|
||||||
|
const pagination = {
|
||||||
|
current_page: data.page || 1,
|
||||||
|
total_pages: data.total_pages || 1,
|
||||||
|
total_entries: data.total || 0,
|
||||||
|
per_page: data.per_page || this.perPage
|
||||||
|
};
|
||||||
|
this.renderPagination(pagination);
|
||||||
|
} else if (data.success === false) {
|
||||||
|
// API returned an error
|
||||||
|
throw new Error(data.error || "Failed to load entries");
|
||||||
|
} else {
|
||||||
|
throw new Error("Unexpected API response format");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading entries:", error);
|
||||||
|
this.showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render entries to the DOM
|
||||||
|
*/
|
||||||
|
renderEntries(entries) {
|
||||||
|
// Hide loading and error states
|
||||||
|
this.entriesLoading.style.display = "none";
|
||||||
|
this.entriesError.style.display = "none";
|
||||||
|
this.entriesList.style.display = "block";
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
this.entriesList.innerHTML = `
|
||||||
|
<div class="no-entries">
|
||||||
|
<p>No entries yet. Be the first to sign the guestbook!</p>
|
||||||
|
<span class="cursor-blink">_</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build entries HTML
|
||||||
|
const entriesHTML = entries
|
||||||
|
.map((entry) => this.renderEntry(entry))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
this.entriesList.innerHTML = entriesHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single entry
|
||||||
|
*/
|
||||||
|
renderEntry(entry) {
|
||||||
|
const date = this.formatDate(entry.timestamp);
|
||||||
|
const nameHTML = entry.website
|
||||||
|
? `<a href="${this.escapeHtml(entry.website)}" target="_blank" rel="noopener noreferrer">${this.escapeHtml(entry.name)}</a>`
|
||||||
|
: this.escapeHtml(entry.name);
|
||||||
|
|
||||||
|
// Display website URL without https:// protocol
|
||||||
|
const websiteDisplay = entry.website
|
||||||
|
? `<span class="entry-separator">|</span><span class="entry-website">${this.formatWebsiteUrl(entry.website)}</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-name">${nameHTML}</span>
|
||||||
|
${websiteDisplay}
|
||||||
|
<span class="entry-date">${date}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-message">${this.escapeHtml(entry.message)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render pagination controls
|
||||||
|
*/
|
||||||
|
renderPagination(pagination) {
|
||||||
|
if (pagination.total_pages <= 1) {
|
||||||
|
this.pagination.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pagination.style.display = "flex";
|
||||||
|
|
||||||
|
const prevDisabled = pagination.current_page === 1 ? "disabled" : "";
|
||||||
|
const nextDisabled =
|
||||||
|
pagination.current_page === pagination.total_pages ? "disabled" : "";
|
||||||
|
|
||||||
|
let pagesHTML = "";
|
||||||
|
|
||||||
|
// Show page numbers (max 5)
|
||||||
|
const startPage = Math.max(1, pagination.current_page - 2);
|
||||||
|
const endPage = Math.min(pagination.total_pages, startPage + 4);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const active = i === pagination.current_page ? "active" : "";
|
||||||
|
pagesHTML += `
|
||||||
|
<button class="pagination-button ${active}" data-page="${i}" ${active ? "disabled" : ""}>
|
||||||
|
${i}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pagination.innerHTML = `
|
||||||
|
<div class="pagination-info">
|
||||||
|
Page ${pagination.current_page} of ${pagination.total_pages}
|
||||||
|
(${pagination.total_entries} entries)
|
||||||
|
</div>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button class="pagination-button" data-page="${pagination.current_page - 1}" ${prevDisabled}>
|
||||||
|
< Prev
|
||||||
|
</button>
|
||||||
|
${pagesHTML}
|
||||||
|
<button class="pagination-button" data-page="${pagination.current_page + 1}" ${nextDisabled}>
|
||||||
|
Next >
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Attach click handlers to pagination buttons
|
||||||
|
this.pagination.querySelectorAll(".pagination-button").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
const page = parseInt(e.target.dataset.page);
|
||||||
|
if (page && page !== pagination.current_page) {
|
||||||
|
this.loadEntries(page);
|
||||||
|
// Scroll to top of entries
|
||||||
|
document
|
||||||
|
.querySelector(".guestbook-entries-container")
|
||||||
|
.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission
|
||||||
|
*/
|
||||||
|
async handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Disable submit button
|
||||||
|
this.submitBtn.disabled = true;
|
||||||
|
this.submitBtn.querySelector(".button-text").textContent = "[ SENDING... ]";
|
||||||
|
|
||||||
|
// Clear previous feedback
|
||||||
|
this.formFeedback.className = "form-feedback";
|
||||||
|
this.formFeedback.textContent = "";
|
||||||
|
|
||||||
|
// Get form data
|
||||||
|
const formData = new FormData(this.form);
|
||||||
|
const data = {
|
||||||
|
name: formData.get("name"),
|
||||||
|
email: formData.get("email"),
|
||||||
|
website: formData.get("website"),
|
||||||
|
message: formData.get("message"),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiUrl}/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Handle success response
|
||||||
|
if (result.success || response.ok) {
|
||||||
|
this.showSuccess(
|
||||||
|
result.message ||
|
||||||
|
"Entry submitted! It will appear after moderation. Thank you!",
|
||||||
|
);
|
||||||
|
this.form.reset();
|
||||||
|
|
||||||
|
// If entry was auto-approved, reload entries
|
||||||
|
if (result.status === "approved") {
|
||||||
|
setTimeout(() => this.loadEntries(1), 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showFormError(result.error || result.message || "Failed to submit entry");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting entry:", error);
|
||||||
|
this.showFormError(
|
||||||
|
"Network error. Please check your connection and try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Re-enable submit button
|
||||||
|
this.submitBtn.disabled = false;
|
||||||
|
this.submitBtn.querySelector(".button-text").textContent = "[ SUBMIT ]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show loading state
|
||||||
|
*/
|
||||||
|
showLoading() {
|
||||||
|
this.entriesLoading.style.display = "block";
|
||||||
|
this.entriesError.style.display = "none";
|
||||||
|
this.entriesList.style.display = "none";
|
||||||
|
this.pagination.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error state
|
||||||
|
*/
|
||||||
|
showError() {
|
||||||
|
this.entriesLoading.style.display = "none";
|
||||||
|
this.entriesError.style.display = "block";
|
||||||
|
this.entriesList.style.display = "none";
|
||||||
|
this.pagination.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show form success message
|
||||||
|
*/
|
||||||
|
showSuccess(message) {
|
||||||
|
this.formFeedback.className = "form-feedback success";
|
||||||
|
this.formFeedback.textContent = `SUCCESS: ${message}`;
|
||||||
|
|
||||||
|
// Auto-hide after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.formFeedback.className = "form-feedback";
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show form error message
|
||||||
|
*/
|
||||||
|
showFormError(message) {
|
||||||
|
this.formFeedback.className = "form-feedback error";
|
||||||
|
this.formFeedback.textContent = `ERROR: ${message}`;
|
||||||
|
|
||||||
|
// Auto-hide after 8 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
this.formFeedback.className = "form-feedback";
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to readable format
|
||||||
|
*/
|
||||||
|
formatDate(dateString) {
|
||||||
|
if (!dateString) return "Unknown date";
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
// Check if date is valid
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return "Unknown date";
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = Math.abs(now - date);
|
||||||
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return "Today";
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return "Yesterday";
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays} days ago`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format website URL for display (remove protocol)
|
||||||
|
*/
|
||||||
|
formatWebsiteUrl(url) {
|
||||||
|
if (!url) return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
// Return hostname + pathname, removing trailing slash
|
||||||
|
let display = urlObj.hostname + urlObj.pathname;
|
||||||
|
return this.escapeHtml(display.replace(/\/$/, ""));
|
||||||
|
} catch (e) {
|
||||||
|
// If URL parsing fails, just remove common protocols
|
||||||
|
return this.escapeHtml(
|
||||||
|
url.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize guestbook when DOM is ready
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
new GuestbookManager();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
new GuestbookManager();
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,19 @@
|
||||||
|
|
||||||
window.terminal = new TerminalShell();
|
window.terminal = new TerminalShell();
|
||||||
|
|
||||||
|
// Analytics tracking
|
||||||
|
function sendAnalyticsHit() {
|
||||||
|
fetch('https://api.ritual.sh/analytics/hit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
// Silently fail - don't block page load on analytics errors
|
||||||
|
console.debug('Analytics tracking failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Function to initialize terminal
|
// Function to initialize terminal
|
||||||
function initTerminal() {
|
function initTerminal() {
|
||||||
// Check if terminal element exists
|
// Check if terminal element exists
|
||||||
|
|
@ -14,8 +27,12 @@ function initTerminal() {
|
||||||
|
|
||||||
// Wait for DOM to be ready
|
// Wait for DOM to be ready
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", initTerminal);
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initTerminal();
|
||||||
|
sendAnalyticsHit();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// DOM already loaded
|
// DOM already loaded
|
||||||
initTerminal();
|
initTerminal();
|
||||||
|
sendAnalyticsHit();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
260
assets/js/lastfm-stats.js
Normal file
260
assets/js/lastfm-stats.js
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
// Last.fm Stats Interactive Module
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
|
||||||
|
const LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
|
||||||
|
|
||||||
|
// Store current stats for export
|
||||||
|
let currentStats = {
|
||||||
|
artists: [],
|
||||||
|
totalTracks: 0,
|
||||||
|
period: "",
|
||||||
|
username: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate timestamps based on period
|
||||||
|
function getTimestamps(period) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
let from;
|
||||||
|
|
||||||
|
if (period === "7day") {
|
||||||
|
from = now - 7 * 24 * 60 * 60; // 7 days
|
||||||
|
} else if (period === "1month") {
|
||||||
|
from = now - 30 * 24 * 60 * 60; // 30 days
|
||||||
|
}
|
||||||
|
|
||||||
|
return { from, to: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch top artists for the specified period
|
||||||
|
async function fetchTopArtists(username, period) {
|
||||||
|
const url = `${LASTFM_API_URL}?method=user.gettopartists&user=${username}&api_key=${LASTFM_API_KEY}&format=json&period=${period}&limit=5`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch top artists: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check for Last.fm API errors
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || "Last.fm API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.topartists?.artist || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch recent tracks to count total scrobbles in period
|
||||||
|
async function fetchTrackCount(username, period) {
|
||||||
|
const { from, to } = getTimestamps(period);
|
||||||
|
const url = `${LASTFM_API_URL}?method=user.getrecenttracks&user=${username}&api_key=${LASTFM_API_KEY}&format=json&from=${from}&to=${to}&limit=1`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch track count: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check for Last.fm API errors
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.message || "Last.fm API error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.recenttracks?.["@attr"]?.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate markdown format
|
||||||
|
function generateMarkdown() {
|
||||||
|
const periodText =
|
||||||
|
currentStats.period === "7day" ? "Past Week" : "Past Month";
|
||||||
|
let markdown = `## Last.fm Stats - ${periodText}\n\n`;
|
||||||
|
markdown += `**Total Tracks:** ${currentStats.totalTracks}\n\n`;
|
||||||
|
markdown += `**Top 5 Artists:**\n\n`;
|
||||||
|
|
||||||
|
currentStats.artists.forEach((artist) => {
|
||||||
|
markdown += `- [${artist.name}](${artist.url}) - ${artist.playcount} plays\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate plain text format
|
||||||
|
function generatePlainText() {
|
||||||
|
const periodText =
|
||||||
|
currentStats.period === "7day" ? "Past Week" : "Past Month";
|
||||||
|
let text = `Last.fm Stats - ${periodText}\n\n`;
|
||||||
|
text += `Total Tracks: ${currentStats.totalTracks}\n\n`;
|
||||||
|
text += `Top 5 Artists:\n\n`;
|
||||||
|
|
||||||
|
currentStats.artists.forEach((artist) => {
|
||||||
|
text += `- ${artist.name} - ${artist.playcount} plays\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
async function copyToClipboard(text, button) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = "Copied!";
|
||||||
|
button.classList.add("copied");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.classList.remove("copied");
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
alert("Failed to copy to clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the stats
|
||||||
|
function displayStats(artists, totalTracks, period, username) {
|
||||||
|
const artistsList = document.getElementById("top-artists");
|
||||||
|
const totalTracksEl = document.getElementById("total-tracks");
|
||||||
|
|
||||||
|
// Store current stats for export
|
||||||
|
currentStats = { artists, totalTracks, period, username };
|
||||||
|
|
||||||
|
// Update total tracks
|
||||||
|
totalTracksEl.textContent = totalTracks;
|
||||||
|
|
||||||
|
// Clear and populate artists list
|
||||||
|
artistsList.innerHTML = "";
|
||||||
|
|
||||||
|
if (artists.length === 0) {
|
||||||
|
artistsList.innerHTML = "<li>No artists found for this period</li>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
artists.forEach((artist) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.innerHTML = `<a href="${artist.url}" target="_blank">${artist.name}</a> - ${artist.playcount} plays`;
|
||||||
|
artistsList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show export buttons
|
||||||
|
const exportButtons = document.getElementById("export-buttons");
|
||||||
|
if (exportButtons) {
|
||||||
|
exportButtons.style.display = "flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide UI elements
|
||||||
|
function setLoadingState(isLoading) {
|
||||||
|
const loading = document.getElementById("stats-loading");
|
||||||
|
const content = document.getElementById("stats-content");
|
||||||
|
const error = document.getElementById("stats-error");
|
||||||
|
const results = document.getElementById("stats-results");
|
||||||
|
|
||||||
|
results.style.display = "block";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
loading.style.display = "block";
|
||||||
|
content.style.display = "none";
|
||||||
|
error.style.display = "none";
|
||||||
|
} else {
|
||||||
|
loading.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const error = document.getElementById("stats-error");
|
||||||
|
const errorMessage = document.getElementById("error-message");
|
||||||
|
const content = document.getElementById("stats-content");
|
||||||
|
|
||||||
|
error.style.display = "block";
|
||||||
|
content.style.display = "none";
|
||||||
|
errorMessage.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showContent() {
|
||||||
|
const error = document.getElementById("stats-error");
|
||||||
|
const content = document.getElementById("stats-content");
|
||||||
|
|
||||||
|
error.style.display = "none";
|
||||||
|
content.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main fetch function
|
||||||
|
async function fetchStats() {
|
||||||
|
const username = document.getElementById("lastfm-username").value.trim();
|
||||||
|
const period = document.getElementById("time-period").value;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
showError("Please enter a Last.fm username");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingState(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch both stats in parallel
|
||||||
|
const [artists, totalTracks] = await Promise.all([
|
||||||
|
fetchTopArtists(username, period),
|
||||||
|
fetchTrackCount(username, period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
displayStats(artists, totalTracks, period, username);
|
||||||
|
showContent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Last.fm stats:", error);
|
||||||
|
showError(
|
||||||
|
error.message ||
|
||||||
|
"Failed to fetch stats. Please check the username and try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
function init() {
|
||||||
|
const fetchButton = document.getElementById("fetch-stats");
|
||||||
|
const usernameInput = document.getElementById("lastfm-username");
|
||||||
|
const copyMarkdownBtn = document.getElementById("copy-markdown");
|
||||||
|
const copyPlainTextBtn = document.getElementById("copy-plaintext");
|
||||||
|
|
||||||
|
if (!fetchButton || !usernameInput) {
|
||||||
|
return; // Not on the stats page
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stats on button click
|
||||||
|
fetchButton.addEventListener("click", fetchStats);
|
||||||
|
|
||||||
|
// Also fetch on Enter key in username input
|
||||||
|
usernameInput.addEventListener("keypress", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
fetchStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy buttons
|
||||||
|
if (copyMarkdownBtn) {
|
||||||
|
copyMarkdownBtn.addEventListener("click", () => {
|
||||||
|
const markdown = generateMarkdown();
|
||||||
|
copyToClipboard(markdown, copyMarkdownBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyPlainTextBtn) {
|
||||||
|
copyPlainTextBtn.addEventListener("click", () => {
|
||||||
|
const plainText = generatePlainText();
|
||||||
|
copyToClipboard(plainText, copyPlainTextBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run init when DOM is loaded
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
68
assets/js/lastfm-utils.js
Normal file
68
assets/js/lastfm-utils.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Shared Last.fm utilities - using global namespace pattern
|
||||||
|
(function(window) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Create global LastFmUtils namespace
|
||||||
|
window.LastFmUtils = {
|
||||||
|
// Last.fm API configuration
|
||||||
|
LASTFM_API_URL: "https://ws.audioscrobbler.com/2.0/",
|
||||||
|
LASTFM_USER: "ritualplays",
|
||||||
|
LASTFM_API_KEY: "3a4fef48fecc593d25e0f9a40df1fefe",
|
||||||
|
|
||||||
|
// Format time difference
|
||||||
|
getTimeAgo: function(timestamp) {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
if (diff < 60) return "Just now";
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
return `${Math.floor(diff / 604800)}w ago`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch recent tracks from Last.fm
|
||||||
|
fetchRecentTracks: async function(limit = 10) {
|
||||||
|
const url = `${this.LASTFM_API_URL}?method=user.getrecenttracks&user=${this.LASTFM_USER}&api_key=${this.LASTFM_API_KEY}&format=json&limit=${limit}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch tracks");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.recenttracks.track;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Last.fm data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Filter tracks to remove duplicates of now playing track
|
||||||
|
// Returns a limited number of unique tracks
|
||||||
|
filterAndLimitTracks: function(tracks, maxTracks = 5) {
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first track is now playing
|
||||||
|
const hasNowPlaying = tracks[0] && tracks[0]["@attr"] && tracks[0]["@attr"].nowplaying;
|
||||||
|
|
||||||
|
if (hasNowPlaying) {
|
||||||
|
// Show now playing + (maxTracks - 1) latest (excluding duplicates of now playing)
|
||||||
|
const nowPlayingTrack = tracks[0];
|
||||||
|
const nowPlayingId = `${nowPlayingTrack.name}-${nowPlayingTrack.artist["#text"]}`;
|
||||||
|
|
||||||
|
// Get remaining tracks, excluding duplicates of now playing
|
||||||
|
const remainingTracks = tracks.slice(1).filter(track => {
|
||||||
|
const trackId = `${track.name}-${track.artist["#text"]}`;
|
||||||
|
return trackId !== nowPlayingId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [nowPlayingTrack, ...remainingTracks.slice(0, maxTracks - 1)];
|
||||||
|
} else {
|
||||||
|
// No now playing, show maxTracks latest
|
||||||
|
return tracks.slice(0, maxTracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})(window);
|
||||||
|
|
@ -83,27 +83,8 @@ if (document.getElementById("starfield")) {
|
||||||
const container = document.getElementById("recent-tracks");
|
const container = document.getElementById("recent-tracks");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const AUDIO_LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
|
|
||||||
const AUDIO_LASTFM_USER = "ritualplays";
|
|
||||||
const AUDIO_LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
|
|
||||||
const AUDIO_TRACK_LIMIT = 6; // Fetch 6 to have enough after filtering
|
const AUDIO_TRACK_LIMIT = 6; // Fetch 6 to have enough after filtering
|
||||||
|
|
||||||
// Fetch recent tracks from Last.fm for audio page
|
|
||||||
async function fetchAudioRecentTracks() {
|
|
||||||
const url = `${AUDIO_LASTFM_API_URL}?method=user.getrecenttracks&user=${AUDIO_LASTFM_USER}&api_key=${AUDIO_LASTFM_API_KEY}&format=json&limit=${AUDIO_TRACK_LIMIT}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch tracks");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.recenttracks.track;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching Last.fm data:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render recent tracks for audio page (horizontal layout)
|
// Render recent tracks for audio page (horizontal layout)
|
||||||
function renderRecentTracks(tracks) {
|
function renderRecentTracks(tracks) {
|
||||||
if (!tracks || tracks.length === 0) {
|
if (!tracks || tracks.length === 0) {
|
||||||
|
|
@ -114,27 +95,8 @@ if (document.getElementById("starfield")) {
|
||||||
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
// Check if first track is now playing
|
// Filter and limit tracks to 5 (excluding duplicates)
|
||||||
const hasNowPlaying = tracks[0] && tracks[0]["@attr"] && tracks[0]["@attr"].nowplaying;
|
const tracksToShow = LastFmUtils.filterAndLimitTracks(tracks, 5);
|
||||||
|
|
||||||
// Filter and limit tracks
|
|
||||||
let tracksToShow;
|
|
||||||
if (hasNowPlaying) {
|
|
||||||
// Show now playing + 4 latest (excluding duplicates of now playing)
|
|
||||||
const nowPlayingTrack = tracks[0];
|
|
||||||
const nowPlayingId = `${nowPlayingTrack.name}-${nowPlayingTrack.artist["#text"]}`;
|
|
||||||
|
|
||||||
// Get remaining tracks, excluding duplicates of now playing
|
|
||||||
const remainingTracks = tracks.slice(1).filter(track => {
|
|
||||||
const trackId = `${track.name}-${track.artist["#text"]}`;
|
|
||||||
return trackId !== nowPlayingId;
|
|
||||||
});
|
|
||||||
|
|
||||||
tracksToShow = [nowPlayingTrack, ...remainingTracks.slice(0, 4)];
|
|
||||||
} else {
|
|
||||||
// No now playing, show 5 latest
|
|
||||||
tracksToShow = tracks.slice(0, 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracksToShow.forEach((track) => {
|
tracksToShow.forEach((track) => {
|
||||||
const isNowPlaying = track["@attr"] && track["@attr"].nowplaying;
|
const isNowPlaying = track["@attr"] && track["@attr"].nowplaying;
|
||||||
|
|
@ -164,12 +126,12 @@ if (document.getElementById("starfield")) {
|
||||||
|
|
||||||
// Initialize Last.fm feed for audio page
|
// Initialize Last.fm feed for audio page
|
||||||
async function initAudioRecentTracks() {
|
async function initAudioRecentTracks() {
|
||||||
const tracks = await fetchAudioRecentTracks();
|
const tracks = await LastFmUtils.fetchRecentTracks(AUDIO_TRACK_LIMIT);
|
||||||
renderRecentTracks(tracks);
|
renderRecentTracks(tracks);
|
||||||
|
|
||||||
// Update every 30 seconds
|
// Update every 30 seconds
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const updatedTracks = await fetchAudioRecentTracks();
|
const updatedTracks = await LastFmUtils.fetchRecentTracks(AUDIO_TRACK_LIMIT);
|
||||||
renderRecentTracks(updatedTracks);
|
renderRecentTracks(updatedTracks);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,39 +82,8 @@ function initMatrixRain() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last.fm API configuration
|
// Media page configuration
|
||||||
const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/";
|
const TRACK_LIMIT = 12; // Fetch 12 to have enough after filtering
|
||||||
const LASTFM_USER = "ritualplays";
|
|
||||||
const LASTFM_API_KEY = "3a4fef48fecc593d25e0f9a40df1fefe";
|
|
||||||
const TRACK_LIMIT = 10;
|
|
||||||
|
|
||||||
// Format time difference
|
|
||||||
function getTimeAgo(timestamp) {
|
|
||||||
const now = Date.now() / 1000;
|
|
||||||
const diff = now - timestamp;
|
|
||||||
|
|
||||||
if (diff < 60) return "Just now";
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
|
||||||
return `${Math.floor(diff / 604800)}w ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch recent tracks from Last.fm
|
|
||||||
async function fetchRecentTracks() {
|
|
||||||
const url = `${LASTFM_API_URL}?method=user.getrecenttracks&user=${LASTFM_USER}&api_key=${LASTFM_API_KEY}&format=json&limit=${TRACK_LIMIT}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch tracks");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.recenttracks.track;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching Last.fm data:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render tracks to the DOM
|
// Render tracks to the DOM
|
||||||
function renderTracks(tracks) {
|
function renderTracks(tracks) {
|
||||||
|
|
@ -129,7 +98,10 @@ function renderTracks(tracks) {
|
||||||
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
tracks.forEach((track) => {
|
// Filter and limit tracks to 10 (excluding duplicates of now playing)
|
||||||
|
const tracksToShow = LastFmUtils.filterAndLimitTracks(tracks, 10);
|
||||||
|
|
||||||
|
tracksToShow.forEach((track) => {
|
||||||
const isNowPlaying = track["@attr"] && track["@attr"].nowplaying;
|
const isNowPlaying = track["@attr"] && track["@attr"].nowplaying;
|
||||||
const trackElement = document.createElement("a");
|
const trackElement = document.createElement("a");
|
||||||
trackElement.href = track.url;
|
trackElement.href = track.url;
|
||||||
|
|
@ -143,7 +115,7 @@ function renderTracks(tracks) {
|
||||||
|
|
||||||
// Get timestamp
|
// Get timestamp
|
||||||
const timestamp = track.date ? track.date.uts : null;
|
const timestamp = track.date ? track.date.uts : null;
|
||||||
const timeAgo = timestamp ? getTimeAgo(timestamp) : "";
|
const timeAgo = timestamp ? LastFmUtils.getTimeAgo(timestamp) : "";
|
||||||
|
|
||||||
trackElement.innerHTML = `
|
trackElement.innerHTML = `
|
||||||
<div class="track-art ${!hasArt ? "no-art" : ""}">
|
<div class="track-art ${!hasArt ? "no-art" : ""}">
|
||||||
|
|
@ -162,12 +134,12 @@ function renderTracks(tracks) {
|
||||||
|
|
||||||
// Initialize Last.fm feed
|
// Initialize Last.fm feed
|
||||||
async function initLastFmFeed() {
|
async function initLastFmFeed() {
|
||||||
const tracks = await fetchRecentTracks();
|
const tracks = await LastFmUtils.fetchRecentTracks(TRACK_LIMIT);
|
||||||
renderTracks(tracks);
|
renderTracks(tracks);
|
||||||
|
|
||||||
// Update every 30 seconds
|
// Update every 30 seconds
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const updatedTracks = await fetchRecentTracks();
|
const updatedTracks = await LastFmUtils.fetchRecentTracks(TRACK_LIMIT);
|
||||||
renderTracks(updatedTracks);
|
renderTracks(updatedTracks);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
assets/js/pgp-copy.js
Normal file
33
assets/js/pgp-copy.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// PGP Key copy functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const copyButtons = document.querySelectorAll('.pgp-copy-trigger');
|
||||||
|
|
||||||
|
copyButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', async function() {
|
||||||
|
const feedback = button.closest('.contact-pgp').querySelector('.pgp-copy-feedback');
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error('Failed to copy PGP key:', err);
|
||||||
|
|
||||||
|
feedback.textContent = 'Failed to copy key';
|
||||||
|
feedback.classList.add('show', 'error');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
feedback.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
color: white;
|
color: white;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
||||||
|
.content-screen {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
> .about-content {
|
> .about-content {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
@ -14,6 +18,125 @@
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-contact-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-label {
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: #0f0;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-color: rgba(0, 255, 0, 0.8);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgp-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgp-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
color: #0f0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 255, 0, 0.2);
|
||||||
|
border-color: rgba(0, 255, 0, 0.6);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-feedback {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: #0f0;
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: #f00;
|
||||||
|
background: rgba(255, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||||
|
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.about-header {
|
.about-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -80px;
|
left: -80px;
|
||||||
|
|
@ -37,6 +160,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .about-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
> .info-badges {
|
> .info-badges {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
@ -55,6 +182,23 @@
|
||||||
transform: rotate(5deg);
|
transform: rotate(5deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-music {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
> .music {
|
||||||
|
scale: 1.6;
|
||||||
|
|
||||||
|
.ipod-group {
|
||||||
|
top: 50%;
|
||||||
|
left: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vu-meter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
padding-top: 330px;
|
padding-top: 330px;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
padding-top: 400px;
|
padding-top: 50px;
|
||||||
|
width: 95%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-intro {
|
.audio-intro {
|
||||||
|
|
@ -102,6 +103,13 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
left: auto;
|
||||||
|
top: auto;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.neon-text {
|
.neon-text {
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
font-size: 5rem;
|
font-size: 5rem;
|
||||||
|
|
@ -159,12 +167,12 @@
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-down(md) {
|
@include media-down(md) {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,6 +198,27 @@
|
||||||
color: inherit;
|
color: inherit;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-cover {
|
||||||
|
@include media-down(lg) {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
@include media-down(md) {
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #00ffff;
|
border-color: #00ffff;
|
||||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
|
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
padding: 1rem;
|
padding: 0rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main container with CRT monitor styling
|
// Main container with CRT monitor styling
|
||||||
|
|
@ -21,13 +21,26 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
padding: 15px 15px 35px 15px;
|
padding: 0px;
|
||||||
border-radius: 8px;
|
border-radius: 0px;
|
||||||
|
border: 0px;
|
||||||
|
margin: 0px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-header {
|
.blog-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brand on bezel
|
// Brand on bezel
|
||||||
|
|
@ -108,6 +121,7 @@
|
||||||
.blogs-screen {
|
.blogs-screen {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 500px;
|
min-height: 500px;
|
||||||
|
height: auto; // Allow content to expand
|
||||||
background: #000;
|
background: #000;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -119,7 +133,7 @@
|
||||||
inset -3px -3px 8px rgba(0, 0, 0, 0.5);
|
inset -3px -3px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
border-radius: 6px;
|
border-radius: 0px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,14 +285,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.tag-filter-link {
|
.tag-filter-link {
|
||||||
background: rgba(255, 153, 0, 0.2);
|
background: rgba(255, 153, 0, 0.2);
|
||||||
border-color: rgba(255, 153, 0, 0.5);
|
border-color: rgba(255, 153, 0, 0.5);
|
||||||
color: #ff9900;
|
color: #ff9900;
|
||||||
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
|
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post title
|
// Post title
|
||||||
|
|
@ -458,6 +470,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px dashed #0f0;
|
||||||
|
margin: 2em 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.read-more {
|
.read-more {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
@ -538,6 +558,170 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contact section styling
|
||||||
|
.contact-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(0, 255, 0, 0.05);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: greenyellow;
|
||||||
|
text-shadow: 0 0 10px rgba(173, 255, 47, 0.5);
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
color: greenyellow;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0f0;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: cyan;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-color: rgba(0, 255, 0, 0.8);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
background: rgba(0, 255, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-email,
|
||||||
|
.contact-pgp {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-label {
|
||||||
|
color: greenyellow;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
text-shadow: 0 0 5px rgba(173, 255, 47, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-email a {
|
||||||
|
color: #0f0;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
border-bottom-color: rgba(0, 255, 0, 0.8);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
background: rgba(0, 255, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgp-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pgp-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 15px;
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
color: #0f0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 255, 0, 0.2);
|
||||||
|
border-color: rgba(0, 255, 0, 0.6);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-feedback {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: #0f0;
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: #f00;
|
||||||
|
background: rgba(255, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||||
|
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Blog image styling
|
// Blog image styling
|
||||||
.blog-img-container {
|
.blog-img-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -555,7 +739,8 @@
|
||||||
background:
|
background:
|
||||||
linear-gradient(#000, #000) padding-box,
|
linear-gradient(#000, #000) padding-box,
|
||||||
linear-gradient(180deg, #0f0, #000) border-box;
|
linear-gradient(180deg, #0f0, #000) border-box;
|
||||||
filter: grayscale(100%) contrast(1.2) brightness(0.9) sepia(100%) hue-rotate(60deg) saturate(300%);
|
filter: grayscale(100%) contrast(1.2) brightness(0.9) sepia(100%)
|
||||||
|
hue-rotate(60deg) saturate(300%);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -590,6 +775,10 @@
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
|
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-post-navigation {
|
.blog-post-navigation {
|
||||||
|
|
|
||||||
534
assets/sass/pages/button-generator.scss
Normal file
534
assets/sass/pages/button-generator.scss
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
@import url(https://fonts.bunny.net/css?family=bebas-neue:400|lato:400,400i,700,700i|montserrat:400,400i,700,700i|open-sans:400,400i,700,700i|oswald:400,700|press-start-2p:400|roboto:400,400i,700,700i|roboto-mono:400,400i,700,700i|vt323:400);
|
||||||
|
|
||||||
|
#button-generator-app {
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
rgba(5, 15, 30, 0.9) 0%,
|
||||||
|
rgba(10, 20, 40, 0.95) 100%
|
||||||
|
);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px rgba(0, 150, 255, 0.15),
|
||||||
|
0 0 50px rgba(255, 120, 0, 0.08),
|
||||||
|
inset 0 0 60px rgba(0, 100, 200, 0.05);
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 150, 255, 0.6),
|
||||||
|
rgba(255, 120, 0, 0.6),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 100, 180, 0.15) 0%,
|
||||||
|
rgba(0, 80, 150, 0.1) 100%
|
||||||
|
);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(0, 150, 255, 0.1),
|
||||||
|
inset 0 0 40px rgba(0, 100, 200, 0.05);
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 100, 180, 1) 0%,
|
||||||
|
rgb(0, 29, 55) 100%
|
||||||
|
);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(0, 150, 255, 0.7),
|
||||||
|
rgba(255, 120, 0, 0.5)
|
||||||
|
);
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #0096ff;
|
||||||
|
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-shadow: 0 0 15px rgba(0, 150, 255, 0.5);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets-container {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 2px solid;
|
||||||
|
border-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 150, 255, 0.5),
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
1;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: rgba(100, 180, 255, 0.9);
|
||||||
|
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 150, 255, 0.4);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wrapper {
|
||||||
|
background: repeating-conic-gradient(
|
||||||
|
rgba(20, 30, 50, 0.8) 0% 25%,
|
||||||
|
rgba(10, 20, 40, 0.9) 0% 50%
|
||||||
|
)
|
||||||
|
50% / 20px 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-canvas {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
//border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 150, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 100, 180, 0.15) 0%,
|
||||||
|
rgba(0, 80, 150, 0.1) 100%
|
||||||
|
);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(0, 150, 255, 0.1),
|
||||||
|
inset 0 0 40px rgba(0, 100, 200, 0.05);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(0, 150, 255, 0.7),
|
||||||
|
rgba(255, 120, 0, 0.5)
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
//display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(100, 180, 255, 0.8);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(5, 15, 30, 0.7);
|
||||||
|
color: rgba(200, 220, 255, 0.95);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(0, 150, 255, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.3),
|
||||||
|
inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
background: rgba(5, 15, 30, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
option {
|
||||||
|
background: rgba(5, 15, 30, 0.95);
|
||||||
|
color: rgba(200, 220, 255, 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font preview in dropdowns
|
||||||
|
select#font-family,
|
||||||
|
select#font-family2 {
|
||||||
|
option[value="Lato"] {
|
||||||
|
font-family: "Lato", sans-serif;
|
||||||
|
}
|
||||||
|
option[value="Roboto"] {
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
|
}
|
||||||
|
option[value="Open Sans"] {
|
||||||
|
font-family: "Open Sans", sans-serif;
|
||||||
|
}
|
||||||
|
option[value="Montserrat"] {
|
||||||
|
font-family: "Montserrat", sans-serif;
|
||||||
|
}
|
||||||
|
option[value="Oswald"] {
|
||||||
|
font-family: "Oswald", sans-serif;
|
||||||
|
}
|
||||||
|
option[value="Bebas Neue"] {
|
||||||
|
font-family: "Bebas Neue", display;
|
||||||
|
}
|
||||||
|
option[value="Roboto Mono"] {
|
||||||
|
font-family: "Roboto Mono", monospace;
|
||||||
|
}
|
||||||
|
option[value="VT323"] {
|
||||||
|
font-family: "VT323", monospace;
|
||||||
|
}
|
||||||
|
option[value="Press Start 2P"] {
|
||||||
|
font-family: "Press Start 2P", display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(0, 100, 180, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 6px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: linear-gradient(135deg, #0096ff, #66ccff);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
|
||||||
|
border: 2px solid rgba(0, 150, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: linear-gradient(135deg, #0096ff, #66ccff);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
|
||||||
|
border: 2px solid rgba(0, 150, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: rgba(5, 15, 30, 0.7);
|
||||||
|
padding: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(0, 150, 255, 0.7);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 150, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Custom file input styling
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: rgba(5, 15, 30, 0.7);
|
||||||
|
color: rgba(200, 220, 255, 0.95);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::file-selector-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 120, 200, 0.3) 0%,
|
||||||
|
rgba(0, 100, 180, 0.2) 100%
|
||||||
|
);
|
||||||
|
color: #66ccff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 150, 255, 0.4) 0%,
|
||||||
|
rgba(255, 100, 0, 0.3) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(0, 150, 255, 0.8);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.4),
|
||||||
|
0 0 20px rgba(255, 120, 0, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(0, 150, 255, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.3),
|
||||||
|
inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
background: rgba(5, 15, 30, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(0, 150, 255, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.3),
|
||||||
|
inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
background: rgba(5, 15, 30, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
border-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 150, 255, 0.5),
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: rgba(100, 180, 255, 0.9);
|
||||||
|
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #66ccff;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 150, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
color: rgba(0, 150, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.collapsed .toggle-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group-content {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 2000px;
|
||||||
|
transition:
|
||||||
|
max-height 0.3s ease,
|
||||||
|
opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.collapsed .control-group-content {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(150, 200, 255, 0.7);
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(0, 100, 180, 0.1);
|
||||||
|
border-left: 3px solid rgba(0, 150, 255, 0.5);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: rgba(100, 180, 255, 0.85);
|
||||||
|
font-family: Verdana, "Trebuchet MS", Tahoma, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 150, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button styles are now in partials/form-controls.scss
|
||||||
|
// Only page-specific overrides here
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom tooltip styles
|
||||||
|
.control-tooltip {
|
||||||
|
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),
|
||||||
|
inset 0 0 20px rgba(0, 150, 255, 0.1);
|
||||||
|
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);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-bottom-color: rgba(0, 120, 200, 0.98);
|
||||||
|
filter: drop-shadow(0 -2px 4px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
336
assets/sass/pages/guestbook.scss
Normal file
336
assets/sass/pages/guestbook.scss
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
@import "../mixins";
|
||||||
|
|
||||||
|
.guestbook-page {
|
||||||
|
color: white;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
.content-screen {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .guestbook-content {
|
||||||
|
width: 50%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
> .wide-item {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guestbook-floppy {
|
||||||
|
margin: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 300px;
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal output styling
|
||||||
|
.terminal-output {
|
||||||
|
font-family: monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: #0f0;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: #ff9900;
|
||||||
|
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ff0000;
|
||||||
|
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guestbook Form
|
||||||
|
.guestbook-form {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #0f0;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
color: #0f0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-shadow: 0 0 3px rgba(0, 255, 0, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(0, 255, 0, 0.4);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: rgba(0, 255, 0, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px rgba(0, 255, 0, 0.3),
|
||||||
|
inset 0 0 10px rgba(0, 255, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-feedback {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
display: block;
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
color: #0f0;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
display: block;
|
||||||
|
background: rgba(255, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||||
|
color: #ff0000;
|
||||||
|
text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
display: block;
|
||||||
|
background: rgba(255, 153, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 153, 0, 0.3);
|
||||||
|
color: #ff9900;
|
||||||
|
text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-button {
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 2px solid rgba(0, 255, 0, 0.4);
|
||||||
|
color: #0f0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-radius: 3px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 255, 0, 0.2);
|
||||||
|
border-color: rgba(0, 255, 0, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 255, 0, 0.4),
|
||||||
|
inset 0 0 10px rgba(0, 255, 0, 0.2);
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: rgba(0, 255, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries List
|
||||||
|
.entries-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-header {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.entry-name {
|
||||||
|
color: #0f0;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0f0;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-website {
|
||||||
|
color: rgba(0, 255, 0, 0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-separator {
|
||||||
|
color: rgba(0, 255, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-date {
|
||||||
|
color: rgba(0, 255, 0, 0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-message {
|
||||||
|
color: #0f0;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.9;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
.loading-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: monospace;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: #0f0;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
color: #0f0;
|
||||||
|
animation: loading-blink 1.5s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
.error-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
.pagination {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(0, 255, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-family: monospace;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
color: rgba(0, 255, 0, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button {
|
||||||
|
background: rgba(0, 255, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
|
color: #0f0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 0 3px rgba(0, 255, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 255, 0, 0.2);
|
||||||
|
border-color: rgba(0, 255, 0, 0.5);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(0, 255, 0, 0.3);
|
||||||
|
border-color: rgba(0, 255, 0, 0.6);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar customization for entries container
|
||||||
|
.guestbook-entries-container .screen-display {
|
||||||
|
@include scrollbar-custom(#0f0, 5px);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,12 @@
|
||||||
|
|
||||||
padding-top: 200px;
|
padding-top: 200px;
|
||||||
|
|
||||||
|
@include media-up(lg) {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 28%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -28,7 +34,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
padding-top: 50px;
|
padding-top: 130px;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,13 +164,17 @@
|
||||||
|
|
||||||
.navigation {
|
.navigation {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
@include media-up(lg) {
|
@include media-up(lg) {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
|
|
@ -176,6 +186,97 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-floppy {
|
||||||
|
width: 44%;
|
||||||
|
transform: rotate(5deg);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.nav-floppy-text {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-floppy-text {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 8000;
|
||||||
|
transform: rotate(10deg);
|
||||||
|
border: 1px solid #0f0;
|
||||||
|
padding: 2px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
opacity: 1;
|
||||||
|
//transform: rotate(0deg);
|
||||||
|
bottom: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cube {
|
||||||
|
width: 106.5px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.nav-cube-text {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-icon {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal {
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cube-text {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 8000;
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
border: 1px solid #0f0;
|
||||||
|
padding: 2px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
opacity: 1;
|
||||||
|
//transform: rotate(0deg);
|
||||||
|
bottom: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.time-display {
|
.time-display {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|
@ -187,6 +288,7 @@
|
||||||
inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 1em;
|
border-radius: 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "Interests and Tools";
|
content: "Interests and Tools";
|
||||||
|
|
@ -210,7 +312,7 @@
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
margin-left: -0.5em;
|
margin-left: -0.5em;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
bottom: 0;
|
bottom: -40px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
assets/sass/pages/lavalamp-adoptable.scss
Normal file
204
assets/sass/pages/lavalamp-adoptable.scss
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// Lava Lamp Adoptable Generator Page Styles
|
||||||
|
// Namespaced to avoid conflicts with existing navigation lava lamp styles
|
||||||
|
|
||||||
|
.adoptable-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 2rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wrapper {
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #ccc 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 10px,
|
||||||
|
10px -10px,
|
||||||
|
-10px 0px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-flexible {
|
||||||
|
width: 40px;
|
||||||
|
height: 75px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-100 {
|
||||||
|
width: 100px;
|
||||||
|
height: 200px;
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-200 {
|
||||||
|
width: 200px;
|
||||||
|
height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lavalamp-preview-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-specific overrides - make buttons full width in this layout
|
||||||
|
#lavalamp-adoptable-app .control-group button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adoptable Lava Lamp Component Styles
|
||||||
|
// Namespaced under .lavalamp-adoptable to avoid conflicts with navigation lava lamp
|
||||||
|
.lavalamp-adoptable {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&::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;
|
||||||
|
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 5% rgba(255, 255, 255, 0.2),
|
||||||
|
inset 0 -2px 5% rgba(0, 0, 0, 0.5);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation for adoptable lava lamp blobs (different from navigation lava lamp)
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,11 +54,19 @@
|
||||||
margin-bottom: 50px;
|
margin-bottom: 50px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
border-image: linear-gradient(90deg, transparent, #ff00ff 20%, #00ffff 50%, #ff00ff 80%, transparent) 1;
|
border-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
#ff00ff 20%,
|
||||||
|
#00ffff 50%,
|
||||||
|
#ff00ff 80%,
|
||||||
|
transparent
|
||||||
|
)
|
||||||
|
1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
pre {
|
> pre {
|
||||||
background: linear-gradient(90deg, #ff00ff, #00ffff, #ff00ff);
|
background: linear-gradient(90deg, #ff00ff, #00ffff, #ff00ff);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
|
@ -70,7 +78,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
font-size: 12px;
|
font-size: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,6 +106,16 @@
|
||||||
gap: 25px;
|
gap: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> h3 {
|
||||||
|
display: none;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-image: linear-gradient(90deg, #ff00ff, #00ffff) 1;
|
||||||
|
@include media-down(lg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Subtle divider between items
|
// Subtle divider between items
|
||||||
.media-item + .media-item {
|
.media-item + .media-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -109,7 +127,13 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, #333 20%, #333 80%, transparent);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
#333 20%,
|
||||||
|
#333 80%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,13 +144,16 @@
|
||||||
gap: 25px;
|
gap: 25px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
background: rgba(0,0,0,0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -154,9 +181,9 @@
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
width: 100%;
|
max-width: 100px;
|
||||||
max-width: 300px;
|
max-height: 100px;
|
||||||
height: 450px;
|
height: auto;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -394,6 +421,12 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
.lastfm-track:nth-child(n + 4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lastfm-loading {
|
.lastfm-loading {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
320
assets/sass/partials/_companion-cube.scss
Normal file
320
assets/sass/partials/_companion-cube.scss
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
.companion-cube-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 2rem auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Ground shadow beneath the cube
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -30%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 250%;
|
||||||
|
height: 50%;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse,
|
||||||
|
rgba(0, 0, 0, 0.7) 0%,
|
||||||
|
rgba(0, 0, 0, 0.5) 40%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(8px);
|
||||||
|
z-index: -1;
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
bottom: -4%;
|
||||||
|
filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(sm) {
|
||||||
|
bottom: -3%;
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-one,
|
||||||
|
.cube-two {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 30;
|
||||||
|
|
||||||
|
.companion-cube-face {
|
||||||
|
animation: dropCycle 24s cubic-bezier(0.68, -0.1, 0.265, 1.1) infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-two .companion-cube-face {
|
||||||
|
animation-delay: -12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropCycle {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-150%);
|
||||||
|
}
|
||||||
|
12.5% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
62.5% {
|
||||||
|
transform: translateY(150%);
|
||||||
|
}
|
||||||
|
62.51%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(-150%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.companion-cube-face {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #e8e0d5 0%, #d0c8bd 50%, #beb6ab 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
clip-path: polygon(
|
||||||
|
/* Top left corner */ 0% 0%,
|
||||||
|
0% 35%,
|
||||||
|
/* Left indent */ 6% 37.5%,
|
||||||
|
6% 62.5%,
|
||||||
|
0% 65%,
|
||||||
|
/* Bottom left corner */ 0% 100%,
|
||||||
|
35% 100%,
|
||||||
|
/* Bottom indent */ 37.5% 94%,
|
||||||
|
62.5% 94%,
|
||||||
|
65% 100%,
|
||||||
|
/* Bottom right corner */ 100% 100%,
|
||||||
|
100% 65%,
|
||||||
|
/* Right indent */ 94% 62.5%,
|
||||||
|
94% 37.5%,
|
||||||
|
100% 35%,
|
||||||
|
/* Top right corner */ 100% 0%,
|
||||||
|
65% 0%,
|
||||||
|
/* Top indent */ 62.5% 6%,
|
||||||
|
37.5% 6%,
|
||||||
|
35% 0%
|
||||||
|
);
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cube-center-circle {
|
||||||
|
position: absolute;
|
||||||
|
width: 70%;
|
||||||
|
height: 70%;
|
||||||
|
background: linear-gradient(135deg, #6a6a6a 0%, #4a4a4a 50%, #3a3a3a 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-inner-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 60%;
|
||||||
|
height: 60%;
|
||||||
|
border: 2px solid #5a5a5a;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #e8e0d5 0%, #d0c8bd 50%, #beb6ab 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grey-bar {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #5a5a5a;
|
||||||
|
|
||||||
|
&.horizontal-top,
|
||||||
|
&.horizontal-bottom {
|
||||||
|
height: 2.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.vertical-left,
|
||||||
|
&.vertical-right {
|
||||||
|
width: 2.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal-top {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 37.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal-bottom {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 37.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.vertical-left {
|
||||||
|
left: 37.5%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.vertical-right {
|
||||||
|
right: 37.5%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pink connector bars extending from center
|
||||||
|
.pink-bar {
|
||||||
|
position: absolute;
|
||||||
|
background: #ff80bf;
|
||||||
|
|
||||||
|
&.vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 5%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-icon {
|
||||||
|
font-size: 5rem;
|
||||||
|
z-index: 3;
|
||||||
|
color: #ffc0e5;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-text-stroke: 2px #8b5a6f;
|
||||||
|
animation: heartPulse 3s ease-in-out infinite;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
font-size: 3rem;
|
||||||
|
-webkit-text-stroke: 1.5px #8b5a6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(sm) {
|
||||||
|
font-size: 2rem;
|
||||||
|
-webkit-text-stroke: 1px #8b5a6f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-icon {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.portal {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
transform: translateX(-10%) scaleY(0);
|
||||||
|
width: 120%;
|
||||||
|
height: 20%;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 8px solid;
|
||||||
|
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at center,
|
||||||
|
rgba(0, 0, 0, 0.95) 0%,
|
||||||
|
rgba(0, 0, 0, 0.85) 60%,
|
||||||
|
rgba(0, 0, 0, 0.6) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
border-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(md) {
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(sm) {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blue-portal {
|
||||||
|
bottom: -10%;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
|
border-color: #0096ff;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 40px rgba(0, 150, 255, 0.3),
|
||||||
|
inset 0 0 20px rgba(0, 150, 255, 0.5);
|
||||||
|
animation: portalCycle 24s cubic-bezier(0.68, -0.1, 0.265, 1.1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orange-portal {
|
||||||
|
top: -10%;
|
||||||
|
z-index: 40;
|
||||||
|
border-color: #ff7800;
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px 8px rgba(255, 120, 0, 0.8),
|
||||||
|
0 0 60px 12px rgba(255, 120, 0, 0.5),
|
||||||
|
inset 0 0 30px rgba(255, 120, 0, 0.6);
|
||||||
|
animation: portalCycle 24s cubic-bezier(0.68, -0.1, 0.265, 1.1) infinite;
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes portalCycle {
|
||||||
|
0%,
|
||||||
|
12% {
|
||||||
|
transform: translateX(-10%) scaleY(1);
|
||||||
|
}
|
||||||
|
12.5%,
|
||||||
|
48% {
|
||||||
|
transform: translateX(-10%) scaleY(0);
|
||||||
|
}
|
||||||
|
49%,
|
||||||
|
60.5% {
|
||||||
|
transform: translateX(-10%) scaleY(1);
|
||||||
|
}
|
||||||
|
61%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(-10%) scaleY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulsing heart animation
|
||||||
|
@keyframes heartPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
aspect-ratio: 300 / 245;
|
aspect-ratio: 300 / 245;
|
||||||
background: linear-gradient(145deg, #b8b8b0, #989888);
|
background: linear-gradient(145deg, #b8b8b0, #989888);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 20px rgba(0, 0, 0, 0.7),
|
0 8px 20px #000000b3,
|
||||||
inset 0 2px 4px rgba(255, 255, 255, 0.3),
|
inset 0 2px 4px rgba(255, 255, 255, 0.3),
|
||||||
inset 0 -2px 4px rgba(0, 0, 0, 0.3);
|
inset 0 -2px 4px rgba(0, 0, 0, 0.3);
|
||||||
padding: 6px 8px 18px 8px;
|
padding: 6px 8px 18px 8px;
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
&::before {
|
&::before {
|
||||||
content: " ";
|
content: " ";
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
&::after {
|
&::after {
|
||||||
content: " ";
|
content: " ";
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
|
|
|
||||||
332
assets/sass/partials/_form-controls.scss
Normal file
332
assets/sass/partials/_form-controls.scss
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
// Reusable Form Controls for Resources Pages
|
||||||
|
// Aperture Science / Portal-themed form inputs and controls
|
||||||
|
|
||||||
|
// Form Groups
|
||||||
|
.form-group,
|
||||||
|
.control-group {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #66ccff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
text-shadow: 0 0 8px rgba(0, 150, 255, 0.4);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
// Override for checkbox labels (when checkbox is inside label)
|
||||||
|
&:has(input[type="checkbox"]) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-shadow: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="color"],
|
||||||
|
select {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: rgba(5, 15, 30, 0.7);
|
||||||
|
color: rgba(200, 220, 255, 0.95);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(0, 150, 255, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.3),
|
||||||
|
inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
background: rgba(5, 15, 30, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(100, 150, 200, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color inputs
|
||||||
|
input[type="color"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range inputs (sliders)
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 100, 180, 0.3) 0%,
|
||||||
|
rgba(0, 150, 255, 0.5) 100%
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
// Webkit browsers (Chrome, Safari, Edge)
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 150, 255, 0.9) 0%,
|
||||||
|
rgba(0, 120, 200, 0.8) 100%
|
||||||
|
);
|
||||||
|
border: 2px solid rgba(0, 150, 255, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px rgba(0, 150, 255, 0.5),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 180, 255, 1) 0%,
|
||||||
|
rgba(255, 120, 0, 0.6) 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.8),
|
||||||
|
0 0 20px rgba(255, 120, 0, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firefox
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 150, 255, 0.9) 0%,
|
||||||
|
rgba(0, 120, 200, 0.8) 100%
|
||||||
|
);
|
||||||
|
border: 2px solid rgba(0, 150, 255, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px rgba(0, 150, 255, 0.5),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 180, 255, 1) 0%,
|
||||||
|
rgba(255, 120, 0, 0.6) 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(0, 150, 255, 0.8),
|
||||||
|
0 0 20px rgba(255, 120, 0, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-track {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 100, 180, 0.3) 0%,
|
||||||
|
rgba(0, 150, 255, 0.5) 100%
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkboxes
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(5, 15, 30, 0.7);
|
||||||
|
border: 2px solid rgba(0, 150, 255, 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&:checked {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 150, 255, 0.5) 0%,
|
||||||
|
rgba(0, 120, 200, 0.4) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(0, 150, 255, 0.7);
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px rgba(0, 150, 255, 0.4),
|
||||||
|
inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked::after {
|
||||||
|
content: "✓";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #66ccff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 0 0 5px rgba(0, 150, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(0, 150, 255, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 0 8px rgba(0, 150, 255, 0.3),
|
||||||
|
inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(0, 150, 255, 0.8);
|
||||||
|
box-shadow:
|
||||||
|
0 0 12px rgba(0, 150, 255, 0.5),
|
||||||
|
inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select dropdowns
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
option {
|
||||||
|
background: rgba(5, 15, 30, 0.95);
|
||||||
|
color: rgba(200, 220, 255, 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared button base styles
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
border: 1px solid rgba(0, 150, 255, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 150, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary Button
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 120, 200, 0.3) 0%,
|
||||||
|
rgba(0, 100, 180, 0.2) 100%
|
||||||
|
);
|
||||||
|
color: #66ccff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 150, 255, 0.4) 0%,
|
||||||
|
rgba(255, 100, 0, 0.3) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(0, 150, 255, 0.8);
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(0, 150, 255, 0.4),
|
||||||
|
0 0 30px rgba(255, 120, 0, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary Button
|
||||||
|
.btn-secondary {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 100, 180, 0.2) 0%,
|
||||||
|
rgba(0, 80, 150, 0.15) 100%
|
||||||
|
);
|
||||||
|
color: #66ccff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(0, 120, 200, 0.3) 0%,
|
||||||
|
rgba(255, 100, 0, 0.2) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba(0, 150, 255, 0.6);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 8px rgba(0, 150, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats Form (specific layout for forms in stats apps)
|
||||||
|
.stats-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
@include media-up(md) {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,10 @@ footer[role="contentinfo"] {
|
||||||
border-left: 1px solid #0f0;
|
border-left: 1px solid #0f0;
|
||||||
border-top-left-radius: 5px;
|
border-top-left-radius: 5px;
|
||||||
|
|
||||||
|
> .crt {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
@include media-down(lg) {
|
@include media-down(lg) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
assets/sass/partials/_graphs.scss
Normal file
174
assets/sass/partials/_graphs.scss
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// Graph container styling to match terminal aesthetic
|
||||||
|
.graph-container {
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 20px 20px 100px 20px; // Extra bottom padding for rotated x-axis labels
|
||||||
|
background: rgba(0, 255, 0, 0.05);
|
||||||
|
border: 2px solid rgba(0, 255, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(0, 255, 0, 0.1),
|
||||||
|
inset 0 0 40px rgba(0, 255, 0, 0.05);
|
||||||
|
overflow: visible; // Prevent clipping of labels
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
padding: 15px 15px 120px 15px;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal-style border glow on hover
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(0, 255, 0, 0.5);
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px rgba(0, 255, 0, 0.2),
|
||||||
|
inset 0 0 40px rgba(0, 255, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanline effect overlay
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 0, 0, 0.1) 0px,
|
||||||
|
rgba(0, 0, 0, 0.1) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph title
|
||||||
|
.graph-title {
|
||||||
|
color: greenyellow;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 10px rgba(173, 255, 47, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 60%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 255, 0, 0.6),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
margin: 8px auto 0;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 255, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas element
|
||||||
|
canvas {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(0, 255, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRT flicker effect (subtle)
|
||||||
|
@keyframes graph-flicker {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animation: graph-flicker 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special styling for graphs in blog posts
|
||||||
|
.blog-summary,
|
||||||
|
.blogs-content {
|
||||||
|
.graph-container {
|
||||||
|
// Ensure proper spacing in blog context
|
||||||
|
margin: 2.5rem 0;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-width graph variant
|
||||||
|
.graph-container.full-width {
|
||||||
|
margin-left: -20px;
|
||||||
|
margin-right: -20px;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
margin-left: -15px;
|
||||||
|
margin-right: -15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact graph variant
|
||||||
|
.graph-container.compact {
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
.graph-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-down(lg) {
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
.graph-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
.graph-container.loading {
|
||||||
|
&::after {
|
||||||
|
content: "LOADING DATA...";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #0f0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,35 @@
|
||||||
|
// Function for standard neon glow effect
|
||||||
|
@function neon-glow($color) {
|
||||||
|
@return 0 0 5px #fff, 0 0 5px #fff, 0 0 21px $color, 0 0 42px $color,
|
||||||
|
0 0 82px $color, 0 0 92px $color, 0 0 142px $color, 0 0 181px $color;
|
||||||
|
}
|
||||||
|
|
||||||
/* Neon sign styling */
|
/* Neon sign styling */
|
||||||
.neon-sign {
|
.neon-sign {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 5rem;
|
line-height: 5rem;
|
||||||
transform: rotate(-10deg);
|
|
||||||
|
.homepage-neon & {
|
||||||
|
transform: rotate(-5deg);
|
||||||
|
margin-left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
@include media-up(lg) {
|
||||||
|
position: absolute;
|
||||||
|
top: 5%;
|
||||||
|
left: 60%;
|
||||||
|
transform: translateX(-50%) rotate(-10deg);
|
||||||
|
z-index: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
overflow: visible;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@include media-up(lg) {
|
@include media-up(lg) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -62,12 +89,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function for standard neon glow effect
|
|
||||||
@function neon-glow($color) {
|
|
||||||
@return 0 0 5px #fff, 0 0 5px #fff, 0 0 21px $color, 0 0 42px $color,
|
|
||||||
0 0 82px $color, 0 0 92px $color, 0 0 142px $color, 0 0 181px $color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mixin for pulse animation - generates keyframes for any color
|
// Mixin for pulse animation - generates keyframes for any color
|
||||||
@mixin neon-pulse-animation($name, $color) {
|
@mixin neon-pulse-animation($name, $color) {
|
||||||
@keyframes #{$name} {
|
@keyframes #{$name} {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
/* VU Meter on desk */
|
/* VU Meter on desk */
|
||||||
.vu-meter {
|
.vu-meter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
|
||||||
top: 10px;
|
top: 10px;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
|
|
||||||
|
@include media-up(md) {
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vu-meter-body {
|
.vu-meter-body {
|
||||||
|
|
@ -40,6 +43,10 @@
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
||||||
|
&.crt {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vu-bar {
|
.vu-bar {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
@import "animations";
|
@import "animations";
|
||||||
|
|
||||||
@import "partials/global-styles";
|
@import "partials/global-styles";
|
||||||
|
@import "partials/form-controls";
|
||||||
|
|
||||||
@import "partials/neon-sign";
|
@import "partials/neon-sign";
|
||||||
@import "partials/music";
|
@import "partials/music";
|
||||||
|
|
@ -14,6 +15,8 @@
|
||||||
@import "partials/lcd-display";
|
@import "partials/lcd-display";
|
||||||
@import "partials/window";
|
@import "partials/window";
|
||||||
@import "partials/crt-tv";
|
@import "partials/crt-tv";
|
||||||
|
@import "partials/graphs";
|
||||||
|
@import "partials/companion-cube";
|
||||||
|
|
||||||
@import "partials/content-screens";
|
@import "partials/content-screens";
|
||||||
|
|
||||||
|
|
@ -24,8 +27,11 @@
|
||||||
@import "pages/blog";
|
@import "pages/blog";
|
||||||
@import "pages/media";
|
@import "pages/media";
|
||||||
@import "pages/resources";
|
@import "pages/resources";
|
||||||
|
@import "pages/button-generator";
|
||||||
|
@import "pages/lavalamp-adoptable";
|
||||||
|
@import "pages/guestbook";
|
||||||
|
|
||||||
@import url("https://fonts.bunny.net/css2?family=Caveat:wght@400..700&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=Neonderthaw&display=swap");
|
@import url(https://fonts.bunny.net/css?family=abel:400|barlow-condensed:400,500|caveat:400|lato:300,300i,400,400i|neonderthaw:400);
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "DSEG7-Classic";
|
font-family: "DSEG7-Classic";
|
||||||
|
|
@ -102,6 +108,7 @@ body {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.crt {
|
.crt {
|
||||||
|
position: absolute;
|
||||||
animation: textShadow 1.6s infinite;
|
animation: textShadow 1.6s infinite;
|
||||||
background: black;
|
background: black;
|
||||||
color: greenyellow;
|
color: greenyellow;
|
||||||
|
|
@ -812,7 +819,6 @@ body {
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Desk-mounted CRT - on desk left side */
|
/* Desk-mounted CRT - on desk left side */
|
||||||
.desk-monitor {
|
.desk-monitor {
|
||||||
bottom: 10%;
|
bottom: 10%;
|
||||||
|
|
@ -1035,8 +1041,6 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@keyframes pulse-slow {
|
@keyframes pulse-slow {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|
|
||||||
32
config.yml
32
config.yml
|
|
@ -6,6 +6,15 @@ buildFuture: false
|
||||||
buildExpired: false
|
buildExpired: false
|
||||||
enableEmoji: true
|
enableEmoji: true
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
home: ["HTML", "RSS", "webmentions"]
|
||||||
|
|
||||||
|
outputFormats:
|
||||||
|
webmentions:
|
||||||
|
mediaType: "application/json"
|
||||||
|
baseName: "webmentions"
|
||||||
|
isPlainText: true
|
||||||
|
|
||||||
pagination:
|
pagination:
|
||||||
pagerSize: 5
|
pagerSize: 5
|
||||||
|
|
||||||
|
|
@ -42,29 +51,6 @@ params:
|
||||||
limit: 10
|
limit: 10
|
||||||
keys: ["title", "permalink", "summary", "content"]
|
keys: ["title", "permalink", "summary", "content"]
|
||||||
|
|
||||||
menu:
|
|
||||||
main:
|
|
||||||
- identifier: about
|
|
||||||
name: about
|
|
||||||
url: /about/
|
|
||||||
weight: 5
|
|
||||||
- identifier: gear
|
|
||||||
name: gear & edc
|
|
||||||
url: /gear/
|
|
||||||
weight: 10
|
|
||||||
- identifier: resources
|
|
||||||
name: resources
|
|
||||||
url: /resources/
|
|
||||||
weight: 12
|
|
||||||
- identifier: archives
|
|
||||||
name: archives
|
|
||||||
url: /archives/
|
|
||||||
weight: 15
|
|
||||||
- identifier: tags
|
|
||||||
name: tags
|
|
||||||
url: /tags/
|
|
||||||
weight: 20
|
|
||||||
|
|
||||||
markup:
|
markup:
|
||||||
highlight:
|
highlight:
|
||||||
noClasses: false
|
noClasses: false
|
||||||
|
|
|
||||||
33
content/blog/2026-01-06-week-3-bit-chilly-out/index.md
Normal file
33
content/blog/2026-01-06-week-3-bit-chilly-out/index.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
title: "Week 3 - Bit Chilly Out"
|
||||||
|
date: 2026-01-11
|
||||||
|
tags:
|
||||||
|
- weeknote
|
||||||
|
- weekly update
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
- ❄️ It's been pretty cold and we've had the lightest sprinkling of snow.
|
||||||
|
- 🏠️ We've started getting the house ready to go on the market ahead of a move this year.
|
||||||
|
- 📱 I did a mobile pass on some of the pages on this website, it works a bit better on phones now.
|
||||||
|
- 🟠 Added the [resources](/resources) section with a script to pull weekly last.fm stats (see example output below!)
|
||||||
|
- 🆒 Started working on an [88x31 button creator](/resources/button-generator/), it's got a decent amount of functionality so far.
|
||||||
|
- 🎁 Added an adoptables section to the resources page, at the moment there's just a [lavalamp generator](/resources/adoptables/) there but I have plans for more!
|
||||||
|
|
||||||
|
## Links I Found Interesting
|
||||||
|
|
||||||
|
- [enclose.horse](https://enclose.horse/) - A daily game where you have to enclose a horse in the largest possible field.
|
||||||
|
|
||||||
|
## Weekly Listening Stats
|
||||||
|
|
||||||
|
**Total Tracks:** 378
|
||||||
|
|
||||||
|
**Top 5 Artists:**
|
||||||
|
|
||||||
|
- [Lazerhawk](https://www.last.fm/music/Lazerhawk) - 37 plays
|
||||||
|
- [Kavinsky](https://www.last.fm/music/Kavinsky) - 27 plays
|
||||||
|
- [Alien Ant Farm](https://www.last.fm/music/Alien+Ant+Farm) - 26 plays
|
||||||
|
- [Linkin Park](https://www.last.fm/music/Linkin+Park) - 15 plays
|
||||||
|
- [Perturbator](https://www.last.fm/music/Perturbator) - 15 plays
|
||||||
|
|
||||||
|
Until next week!
|
||||||
24
content/blog/2026-01-12-week-4-got-webmentions/index.md
Normal file
24
content/blog/2026-01-12-week-4-got-webmentions/index.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
title: "Week 4 - Got Webmentions"
|
||||||
|
date: 2026-01-18
|
||||||
|
tags:
|
||||||
|
- weeknote
|
||||||
|
- weekly update
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
- 🗨️ Added the ability to send and receive [webmentions](https://indieweb.org/Webmention) to the blog. Haven't automated displaying of received ones yet, but I'll get there.
|
||||||
|
- 🧰 Setup a little personal API to handle the guestbook, visitor counter, and aforementioned webmentions.
|
||||||
|
- 📺 Setup a little dashboard on my homelab for monitoring all of the above!
|
||||||
|
|
||||||
|
## Links I Found Interesting
|
||||||
|
|
||||||
|
- [iCloud Photos Downloader](https://github.com/icloud-photos-downloader/icloud_photos_downloader) and [Photos Backup Anywhere](https://photosbackup.app/) - Two useful looking things for backing up my Photos library
|
||||||
|
|
||||||
|
## Music
|
||||||
|
|
||||||
|
## Next Week
|
||||||
|
|
||||||
|
Not necessary
|
||||||
|
|
||||||
|
Until next week!
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
Month,ai coding: (Worldwide)
|
||||||
|
2019-01,1
|
||||||
|
2019-02,1
|
||||||
|
2019-03,1
|
||||||
|
2019-04,1
|
||||||
|
2019-05,1
|
||||||
|
2019-06,1
|
||||||
|
2019-07,1
|
||||||
|
2019-08,1
|
||||||
|
2019-09,1
|
||||||
|
2019-10,1
|
||||||
|
2019-11,1
|
||||||
|
2019-12,1
|
||||||
|
2020-01,1
|
||||||
|
2020-02,1
|
||||||
|
2020-03,1
|
||||||
|
2020-04,1
|
||||||
|
2020-05,1
|
||||||
|
2020-06,1
|
||||||
|
2020-07,1
|
||||||
|
2020-08,1
|
||||||
|
2020-09,1
|
||||||
|
2020-10,1
|
||||||
|
2020-11,1
|
||||||
|
2020-12,1
|
||||||
|
2021-01,1
|
||||||
|
2021-02,1
|
||||||
|
2021-03,1
|
||||||
|
2021-04,1
|
||||||
|
2021-05,1
|
||||||
|
2021-06,1
|
||||||
|
2021-07,1
|
||||||
|
2021-08,1
|
||||||
|
2021-09,1
|
||||||
|
2021-10,1
|
||||||
|
2021-11,1
|
||||||
|
2021-12,1
|
||||||
|
2022-01,1
|
||||||
|
2022-02,1
|
||||||
|
2022-03,1
|
||||||
|
2022-04,1
|
||||||
|
2022-05,1
|
||||||
|
2022-06,1
|
||||||
|
2022-07,1
|
||||||
|
2022-08,1
|
||||||
|
2022-09,1
|
||||||
|
2022-10,1
|
||||||
|
2022-11,1
|
||||||
|
2022-12,3
|
||||||
|
2023-01,4
|
||||||
|
2023-02,5
|
||||||
|
2023-03,5
|
||||||
|
2023-04,6
|
||||||
|
2023-05,7
|
||||||
|
2023-06,7
|
||||||
|
2023-07,7
|
||||||
|
2023-08,7
|
||||||
|
2023-09,7
|
||||||
|
2023-10,7
|
||||||
|
2023-11,8
|
||||||
|
2023-12,8
|
||||||
|
2024-01,9
|
||||||
|
2024-02,10
|
||||||
|
2024-03,14
|
||||||
|
2024-04,12
|
||||||
|
2024-05,11
|
||||||
|
2024-06,11
|
||||||
|
2024-07,10
|
||||||
|
2024-08,11
|
||||||
|
2024-09,14
|
||||||
|
2024-10,16
|
||||||
|
2024-11,18
|
||||||
|
2024-12,19
|
||||||
|
2025-01,21
|
||||||
|
2025-02,27
|
||||||
|
2025-03,31
|
||||||
|
2025-04,32
|
||||||
|
2025-05,36
|
||||||
|
2025-06,53
|
||||||
|
2025-07,58
|
||||||
|
2025-08,100
|
||||||
|
2025-09,89
|
||||||
|
2025-10,82
|
||||||
|
2025-11,92
|
||||||
|
2025-12,82
|
||||||
|
85
content/blog/the-downfall-of-stackoverflow/ai-trend.csv
Normal file
85
content/blog/the-downfall-of-stackoverflow/ai-trend.csv
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
Month,ai: (Worldwide)
|
||||||
|
2019-01,5
|
||||||
|
2019-02,5
|
||||||
|
2019-03,5
|
||||||
|
2019-04,5
|
||||||
|
2019-05,5
|
||||||
|
2019-06,5
|
||||||
|
2019-07,5
|
||||||
|
2019-08,5
|
||||||
|
2019-09,5
|
||||||
|
2019-10,5
|
||||||
|
2019-11,5
|
||||||
|
2019-12,5
|
||||||
|
2020-01,5
|
||||||
|
2020-02,5
|
||||||
|
2020-03,5
|
||||||
|
2020-04,6
|
||||||
|
2020-05,6
|
||||||
|
2020-06,5
|
||||||
|
2020-07,5
|
||||||
|
2020-08,5
|
||||||
|
2020-09,5
|
||||||
|
2020-10,5
|
||||||
|
2020-11,5
|
||||||
|
2020-12,5
|
||||||
|
2021-01,5
|
||||||
|
2021-02,5
|
||||||
|
2021-03,6
|
||||||
|
2021-04,6
|
||||||
|
2021-05,6
|
||||||
|
2021-06,6
|
||||||
|
2021-07,5
|
||||||
|
2021-08,6
|
||||||
|
2021-09,6
|
||||||
|
2021-10,6
|
||||||
|
2021-11,6
|
||||||
|
2021-12,6
|
||||||
|
2022-01,7
|
||||||
|
2022-02,7
|
||||||
|
2022-03,7
|
||||||
|
2022-04,7
|
||||||
|
2022-05,7
|
||||||
|
2022-06,7
|
||||||
|
2022-07,7
|
||||||
|
2022-08,7
|
||||||
|
2022-09,8
|
||||||
|
2022-10,8
|
||||||
|
2022-11,8
|
||||||
|
2022-12,12
|
||||||
|
2023-01,12
|
||||||
|
2023-02,15
|
||||||
|
2023-03,16
|
||||||
|
2023-04,21
|
||||||
|
2023-05,28
|
||||||
|
2023-06,27
|
||||||
|
2023-07,26
|
||||||
|
2023-08,26
|
||||||
|
2023-09,25
|
||||||
|
2023-10,29
|
||||||
|
2023-11,30
|
||||||
|
2023-12,30
|
||||||
|
2024-01,32
|
||||||
|
2024-02,33
|
||||||
|
2024-03,36
|
||||||
|
2024-04,34
|
||||||
|
2024-05,36
|
||||||
|
2024-06,34
|
||||||
|
2024-07,34
|
||||||
|
2024-08,39
|
||||||
|
2024-09,39
|
||||||
|
2024-10,42
|
||||||
|
2024-11,43
|
||||||
|
2024-12,43
|
||||||
|
2025-01,47
|
||||||
|
2025-02,51
|
||||||
|
2025-03,54
|
||||||
|
2025-04,55
|
||||||
|
2025-05,52
|
||||||
|
2025-06,57
|
||||||
|
2025-07,62
|
||||||
|
2025-08,73
|
||||||
|
2025-09,100
|
||||||
|
2025-10,92
|
||||||
|
2025-11,90
|
||||||
|
2025-12,80
|
||||||
|
137
content/blog/the-downfall-of-stackoverflow/index.md
Normal file
137
content/blog/the-downfall-of-stackoverflow/index.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
---
|
||||||
|
title: "The Downfall of StackOverflow"
|
||||||
|
date: 2026-01-04T13:58:02Z
|
||||||
|
tags:
|
||||||
|
- analysis
|
||||||
|
- ai
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
_Quick note: This isn't an "AI bad" rant. AI tools are a useful thing to have in your kit. This is just an observational look at what the data shows about StackOverflow's decline. That being said, the sooner the AI bubble bursts the better._
|
||||||
|
|
||||||
|
This post was inspired by a post on [Hacker News](https://news.ycombinator.com/item?id=46482345) that linked to this [StackOverflow data](https://data.stackexchange.com/stackoverflow/query/1926661#graph).
|
||||||
|
|
||||||
|
My kneejerk reaction to the data was that the rise in AI and its code analysis capabilities have caused the downfall of StackOverflow, but I needed some data to back it up.
|
||||||
|
|
||||||
|
We can see a peak after a gradual decline in early 2020 (COVID bedroom coders?) which then returns to a roughly normal level by 2021, before starting a stark decline into obscurity, very much accelerating at the end of 2022.
|
||||||
|
|
||||||
|
{{< graph id="stackoverflow-trend" type="line" title="Stack Overflow Questions Over Time" height="500" csv="stackoverflow_questions_over_time.csv" labelColumn="Month" dataColumn="" dateFormat="2006-01" >}}
|
||||||
|
{{< /graph >}}
|
||||||
|
|
||||||
|
## The rise and fall
|
||||||
|
|
||||||
|
StackOverflow had a hell of a run. From just 4 questions monthly in July 2008 to over 207,000 in March 2014: six years of basically uninterrupted growth. It became _the_ place every developer went when they were stuck.
|
||||||
|
|
||||||
|
Then around 2014-2015, it plateaued. About 170,000-190,000 questions per month, which held steady for a few years before starting to slip. By 2019 we're down to around 150,000 per month. Still solid, but the writing was on the wall.
|
||||||
|
|
||||||
|
Then it properly falls off a cliff. January 2023: 97,209 questions. December 2023: 42,601. December 2025: 3,862. That's a 98.1% drop from the 2020 peak.
|
||||||
|
|
||||||
|
## Is it AI?
|
||||||
|
|
||||||
|
Looking at Google Trends[^1] for AI-related searches, there's a bit of a gap between when StackOverflow started dying and when AI actually took off.
|
||||||
|
|
||||||
|
{{< graph id="ai-trends" type="line" title="AI Search Trends" height="500"
|
||||||
|
csv="ai-coding-trend.csv,ai-trend.csv"
|
||||||
|
labelColumn="Month"
|
||||||
|
dataColumns="ai coding: (Worldwide),ai: (Worldwide)"
|
||||||
|
datasetLabels="AI Coding,AI General"
|
||||||
|
dateFormat="2006-01" >}}
|
||||||
|
{{< /graph >}}
|
||||||
|
|
||||||
|
Both general AI interest and AI coding searches were basically flat from 2019 through most of 2022. Then in December 2022 it spikes. That's ChatGPT launching[^2]. This is seen conversely in the data on StackOverflow Questions declining at the same point.
|
||||||
|
|
||||||
|
Zooming in on 2019 onwards makes it clearer:
|
||||||
|
|
||||||
|
{{< graph id="ai-trends-vs-questions" type="line" title="AI Search Trends vs StackOverflow Questions" height="500"
|
||||||
|
csv="stackoverflow_questions_after_2019.csv,ai-coding-trend.csv,ai-trend.csv"
|
||||||
|
labelColumn="Month"
|
||||||
|
dataColumns="Questions,ai coding: (Worldwide),ai: (Worldwide)"
|
||||||
|
datasetLabels="StackOverflow Questions,AI Coding,AI General"
|
||||||
|
yAxisIDs="y,y1,y1"
|
||||||
|
dateFormat="2006-01" >}}
|
||||||
|
{{< /graph >}}
|
||||||
|
|
||||||
|
## The timeline doesn't quite add up
|
||||||
|
|
||||||
|
StackOverflow's real decline starts in 2021, well before anyone gave a shit about AI coding. From January 2021 (140,009 questions) to December 2022 (96,767 questions), it lost 31% of its traffic while "AI coding" searches sat at baseline.
|
||||||
|
|
||||||
|
The AI coding surge doesn't really kick off until early 2023, then explodes through 2025, peaking in August. By then StackOverflow was already down to 5,885 questions per month.
|
||||||
|
|
||||||
|
## Did the rise of GitHub contribute?
|
||||||
|
|
||||||
|
A few comments[^3] on Hacker News suggested that the rise of GitHub could have been a contributor to the decline of StackOverflow. On the surface that sounds reasonable, a lot of support happens directly on GitHub nowadays, which wasn't really a thing during StackOverflow's peak.
|
||||||
|
|
||||||
|
To check this I grabbed data from the top 100 starred GitHub repositories and tracked issues opened per month. This isn't an ideal metric[^4], but it gives us something to work with.
|
||||||
|
|
||||||
|
{{< graph id="stackoverflow-vs-github-issues" type="line" title="StackOverflow Questions vs GitHub Issues" height="500"
|
||||||
|
csv="stackoverflow_questions_over_time.csv,total_issues_by_date.csv"
|
||||||
|
labelColumn="Month"
|
||||||
|
dataColumns="Questions,Total Issues"
|
||||||
|
datasetLabels="StackOverflow Questions,GitHub Issues"
|
||||||
|
yAxisIDs="y,y1"
|
||||||
|
dateFormat="2006-01" >}}
|
||||||
|
{{< /graph >}}
|
||||||
|
|
||||||
|
GitHub issues start slow in these specific repositories, but by 2019 we're seeing around 9,000-10,000 issues per month, which plateaus and holds relatively steady through 2025 (hovering between 10,000-14,000).
|
||||||
|
|
||||||
|
The timeline doesn't really match StackOverflow's decline either. GitHub issues hit a high in 2018-2019 (when they jumped to 8,000-10,000 per month), but StackOverflow was still holding steady at 150,000+ questions. The platform only started its real nosedive in 2021-2023, while GitHub issues stayed flat.
|
||||||
|
|
||||||
|
If GitHub issues were the culprit, you'd expect to see an inverse relationship: as issues go up, questions go down. Instead, both coexisted just fine from 2015-2020. GitHub issues plateaued in 2019 and have stayed relatively constant since, even slightly increasing in 2025. Meanwhile, StackOverflow collapsed by 98.1%.
|
||||||
|
|
||||||
|
So no, I don't believe GitHub issues killed StackOverflow. It may have been a contributing factor, with people going elsewhere to find answers. Developers were using both for years without real conflict though.
|
||||||
|
|
||||||
|
## What about toxicity?
|
||||||
|
|
||||||
|
Several comments[^5] brought up StackOverflow's harsh moderation and toxic environment as a major factor. The "marked as duplicate," "this question doesn't belong here," or outright hostile responses to newbies are easier to find than actual answers most of the time.
|
||||||
|
|
||||||
|
Wasn't StackOverflow _always_ like that? The platform had a reputation for being unwelcoming since at least 2012-2013[^6], right during its peak growth years. If toxicity was the primary killer, you'd expect to see the decline start much earlier, not suddenly accelerate in 2021-2023.
|
||||||
|
|
||||||
|
Could it be a contributing factor? Absolutely. If you had a choice between asking a question on StackOverflow (and risking getting dunked on by some pedantic asshole) versus just asking ChatGPT (which never judges you 😘), the choice is obvious. But toxicity alone doesn't explain the timing or the scale of the collapse.
|
||||||
|
|
||||||
|
To actually test this properly you'd need to pull years of StackOverflow comments and run sentiment analysis to see if moderation got worse over time. That's way more effort than I'm putting into this blog post. Maybe one for another day.
|
||||||
|
|
||||||
|
## So what's was the actual cause?
|
||||||
|
|
||||||
|
The data suggests AI accelerated something that was already happening. A few things probably contributed:
|
||||||
|
|
||||||
|
**The saturation effect**: By 2021, StackOverflow had 16+ years of answered questions. How many times can you ask "how do I parse JSON in Python" before every variation is covered? The "just Google it" response became the correct answer because everything _had_ been Googled already.
|
||||||
|
|
||||||
|
**The pre-AI decline (2021-2022)**: 31% drop over 18 months while AI coding searches were dead flat. This points to other shifts: better documentation, clearer error messages, frameworks maturing and becoming less footgun-y. Developers were finding answers without needing to ask.
|
||||||
|
|
||||||
|
**The AI acceleration (2023-2025)**: ChatGPT launches November 30, 2022. By March 2023, StackOverflow drops from 123,614 questions to 87,543. AI tools give you instant answers without needing to wade through ten variations of your question that are marked as duplicates and locked.
|
||||||
|
|
||||||
|
**The collapse (2025-2026)**: By mid-2025, AI coding tools are just... everywhere. GitHub Copilot, ChatGPT, Claude, all of them baked into every IDE and workflow. The August 2025 peak in "AI coding" searches lines up with StackOverflow hitting 5,885 questions. That's a 96% decline from five years earlier.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
AI didn't kill StackOverflow, but it's definitely finishing the job. The platform was already bleeding out when ChatGPT showed up: content saturation, better tooling, the ecosystem maturing. But AI coding tools changed the game completely. Why search through old forum posts when you can just ask?
|
||||||
|
|
||||||
|
The inverse relationship is stark: as AI coding interest hits its peak in 2025, StackOverflow craters. By December 2025 we're at 3,862 questions. That's roughly the same as September 2008, just a few months after the platform launched.
|
||||||
|
|
||||||
|
Twelve years to build it. Five years to watch it end. The last three were almost certainly AI finishing what was already started.
|
||||||
|
|
||||||
|
### Footnote
|
||||||
|
|
||||||
|
This is the hardest and longest I have thought about anything in a long time. I was an avid StackOverflow user 10~ years ago, and the graph showing up on Hacker News showing the current state absolutely blew my mind and inspired me to dig a bit deeper.
|
||||||
|
|
||||||
|
The CSV data for this post can be found on my [GitHub](https://github.com/unbolt/ritual.sh/tree/main/content/blog/the-downfall-of-stackoverflow).
|
||||||
|
|
||||||
|
I hope you found this interesting. Please get in touch if you have any further insights.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Corrections and Updates
|
||||||
|
|
||||||
|
- 04/01/2025 - Removed the January 2026 data from all graphs as we're only 4 days in and it made it look weird. Also added some 0 value months to the GitHub data at the start so the graphs line up properly visually.
|
||||||
|
|
||||||
|
[^1]: Google Trends data provides relative search interest rather than absolute numbers, which may not capture the full picture.
|
||||||
|
|
||||||
|
[^2]: [ChatGPT Launch Announcement](https://openai.com/index/chatgpt/)
|
||||||
|
|
||||||
|
[^3]: [Comment](https://news.ycombinator.com/item?id=46486171), [Comment](https://news.ycombinator.com/item?id=46487601), [Comment](https://news.ycombinator.com/item?id=46482758) + More.
|
||||||
|
|
||||||
|
[^4]: Read: It's bad. There's a couple of massive outlier projects with a LOT more issues raised than others, but that's a topic for another blog.
|
||||||
|
|
||||||
|
[^5]: [Comment](https://news.ycombinator.com/item?id=46482624)
|
||||||
|
|
||||||
|
[^6]: In 2013, Tim Schreiber wrote about ["StackOverlords"](https://timschreiber.com/2013/10/30/beware-the-stackoverlords/) who "ruthlessly wield" their privileges against newcomers. By 2018, StackOverflow officially acknowledged the problem in their blog post ["Stack Overflow Isn't Very Welcoming. It's Time for That to Change."](https://stackoverflow.blog/2018/04/26/stack-overflow-isnt-very-welcoming-its-time-for-that-to-change/)
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
Month,Questions
|
||||||
|
"2019-01-01 00:00:00","149628"
|
||||||
|
"2019-02-01 00:00:00","146421"
|
||||||
|
"2019-03-01 00:00:00","161159"
|
||||||
|
"2019-04-01 00:00:00","153558"
|
||||||
|
"2019-05-01 00:00:00","151393"
|
||||||
|
"2019-06-01 00:00:00","135935"
|
||||||
|
"2019-07-01 00:00:00","151081"
|
||||||
|
"2019-08-01 00:00:00","137181"
|
||||||
|
"2019-09-01 00:00:00","136935"
|
||||||
|
"2019-10-01 00:00:00","152721"
|
||||||
|
"2019-11-01 00:00:00","148338"
|
||||||
|
"2019-12-01 00:00:00","132623"
|
||||||
|
"2020-01-01 00:00:00","146735"
|
||||||
|
"2020-02-01 00:00:00","145191"
|
||||||
|
"2020-03-01 00:00:00","156037"
|
||||||
|
"2020-04-01 00:00:00","183016"
|
||||||
|
"2020-05-01 00:00:00","186573"
|
||||||
|
"2020-06-01 00:00:00","172002"
|
||||||
|
"2020-07-01 00:00:00","166135"
|
||||||
|
"2020-08-01 00:00:00","148452"
|
||||||
|
"2020-09-01 00:00:00","141887"
|
||||||
|
"2020-10-01 00:00:00","141948"
|
||||||
|
"2020-11-01 00:00:00","135088"
|
||||||
|
"2020-12-01 00:00:00","134035"
|
||||||
|
"2021-01-01 00:00:00","140009"
|
||||||
|
"2021-02-01 00:00:00","131810"
|
||||||
|
"2021-03-01 00:00:00","148900"
|
||||||
|
"2021-04-01 00:00:00","136022"
|
||||||
|
"2021-05-01 00:00:00","133911"
|
||||||
|
"2021-06-01 00:00:00","129106"
|
||||||
|
"2021-07-01 00:00:00","124130"
|
||||||
|
"2021-08-01 00:00:00","122273"
|
||||||
|
"2021-09-01 00:00:00","119882"
|
||||||
|
"2021-10-01 00:00:00","119008"
|
||||||
|
"2021-11-01 00:00:00","119260"
|
||||||
|
"2021-12-01 00:00:00","112278"
|
||||||
|
"2022-01-01 00:00:00","119459"
|
||||||
|
"2022-02-01 00:00:00","114114"
|
||||||
|
"2022-03-01 00:00:00","123614"
|
||||||
|
"2022-04-01 00:00:00","114422"
|
||||||
|
"2022-05-01 00:00:00","116346"
|
||||||
|
"2022-06-01 00:00:00","111741"
|
||||||
|
"2022-07-01 00:00:00","111059"
|
||||||
|
"2022-08-01 00:00:00","113048"
|
||||||
|
"2022-09-01 00:00:00","103965"
|
||||||
|
"2022-10-01 00:00:00","106366"
|
||||||
|
"2022-11-01 00:00:00","109719"
|
||||||
|
"2022-12-01 00:00:00","96767"
|
||||||
|
"2023-01-01 00:00:00","97209"
|
||||||
|
"2023-02-01 00:00:00","85973"
|
||||||
|
"2023-03-01 00:00:00","87543"
|
||||||
|
"2023-04-01 00:00:00","68746"
|
||||||
|
"2023-05-01 00:00:00","66749"
|
||||||
|
"2023-06-01 00:00:00","63858"
|
||||||
|
"2023-07-01 00:00:00","62938"
|
||||||
|
"2023-08-01 00:00:00","60319"
|
||||||
|
"2023-09-01 00:00:00","53046"
|
||||||
|
"2023-10-01 00:00:00","52743"
|
||||||
|
"2023-11-01 00:00:00","50646"
|
||||||
|
"2023-12-01 00:00:00","42601"
|
||||||
|
"2024-01-01 00:00:00","47854"
|
||||||
|
"2024-02-01 00:00:00","46292"
|
||||||
|
"2024-03-01 00:00:00","45070"
|
||||||
|
"2024-04-01 00:00:00","42776"
|
||||||
|
"2024-05-01 00:00:00","40485"
|
||||||
|
"2024-06-01 00:00:00","32243"
|
||||||
|
"2024-07-01 00:00:00","31740"
|
||||||
|
"2024-08-01 00:00:00","28059"
|
||||||
|
"2024-09-01 00:00:00","24947"
|
||||||
|
"2024-10-01 00:00:00","23319"
|
||||||
|
"2024-11-01 00:00:00","20891"
|
||||||
|
"2024-12-01 00:00:00","18029"
|
||||||
|
"2025-01-01 00:00:00","22394"
|
||||||
|
"2025-02-01 00:00:00","19340"
|
||||||
|
"2025-03-01 00:00:00","18965"
|
||||||
|
"2025-04-01 00:00:00","14138"
|
||||||
|
"2025-05-01 00:00:00","11824"
|
||||||
|
"2025-06-01 00:00:00","9392"
|
||||||
|
"2025-07-01 00:00:00","7841"
|
||||||
|
"2025-08-01 00:00:00","5885"
|
||||||
|
"2025-09-01 00:00:00","6132"
|
||||||
|
"2025-10-01 00:00:00","5415"
|
||||||
|
"2025-11-01 00:00:00","4366"
|
||||||
|
"2025-12-01 00:00:00","3862"
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
Month,Questions
|
||||||
|
"2008-07-01 00:00:00","4"
|
||||||
|
"2008-08-01 00:00:00","3749"
|
||||||
|
"2008-09-01 00:00:00","14040"
|
||||||
|
"2008-10-01 00:00:00","14578"
|
||||||
|
"2008-11-01 00:00:00","12717"
|
||||||
|
"2008-12-01 00:00:00","12081"
|
||||||
|
"2009-01-01 00:00:00","15833"
|
||||||
|
"2009-02-01 00:00:00","17581"
|
||||||
|
"2009-03-01 00:00:00","20482"
|
||||||
|
"2009-04-01 00:00:00","21336"
|
||||||
|
"2009-05-01 00:00:00","25814"
|
||||||
|
"2009-06-01 00:00:00","28326"
|
||||||
|
"2009-07-01 00:00:00","32483"
|
||||||
|
"2009-08-01 00:00:00","32775"
|
||||||
|
"2009-09-01 00:00:00","33054"
|
||||||
|
"2009-10-01 00:00:00","36331"
|
||||||
|
"2009-11-01 00:00:00","38518"
|
||||||
|
"2009-12-01 00:00:00","37795"
|
||||||
|
"2010-01-01 00:00:00","44938"
|
||||||
|
"2010-02-01 00:00:00","44811"
|
||||||
|
"2010-03-01 00:00:00","52236"
|
||||||
|
"2010-04-01 00:00:00","50084"
|
||||||
|
"2010-05-01 00:00:00","51996"
|
||||||
|
"2010-06-01 00:00:00","55750"
|
||||||
|
"2010-07-01 00:00:00","60848"
|
||||||
|
"2010-08-01 00:00:00","63660"
|
||||||
|
"2010-09-01 00:00:00","61230"
|
||||||
|
"2010-10-01 00:00:00","63777"
|
||||||
|
"2010-11-01 00:00:00","69693"
|
||||||
|
"2010-12-01 00:00:00","69573"
|
||||||
|
"2011-01-01 00:00:00","79911"
|
||||||
|
"2011-02-01 00:00:00","82942"
|
||||||
|
"2011-03-01 00:00:00","100970"
|
||||||
|
"2011-04-01 00:00:00","95562"
|
||||||
|
"2011-05-01 00:00:00","100229"
|
||||||
|
"2011-06-01 00:00:00","99027"
|
||||||
|
"2011-07-01 00:00:00","100445"
|
||||||
|
"2011-08-01 00:00:00","106917"
|
||||||
|
"2011-09-01 00:00:00","101722"
|
||||||
|
"2011-10-01 00:00:00","101032"
|
||||||
|
"2011-11-01 00:00:00","108450"
|
||||||
|
"2011-12-01 00:00:00","103172"
|
||||||
|
"2012-01-01 00:00:00","115434"
|
||||||
|
"2012-02-01 00:00:00","123299"
|
||||||
|
"2012-03-01 00:00:00","133000"
|
||||||
|
"2012-04-01 00:00:00","128064"
|
||||||
|
"2012-05-01 00:00:00","133515"
|
||||||
|
"2012-06-01 00:00:00","130323"
|
||||||
|
"2012-07-01 00:00:00","141906"
|
||||||
|
"2012-08-01 00:00:00","141528"
|
||||||
|
"2012-09-01 00:00:00","132241"
|
||||||
|
"2012-10-01 00:00:00","150083"
|
||||||
|
"2012-11-01 00:00:00","148310"
|
||||||
|
"2012-12-01 00:00:00","135471"
|
||||||
|
"2013-01-01 00:00:00","157850"
|
||||||
|
"2013-02-01 00:00:00","153402"
|
||||||
|
"2013-03-01 00:00:00","173814"
|
||||||
|
"2013-04-01 00:00:00","171546"
|
||||||
|
"2013-05-01 00:00:00","166468"
|
||||||
|
"2013-06-01 00:00:00","158334"
|
||||||
|
"2013-07-01 00:00:00","176636"
|
||||||
|
"2013-08-01 00:00:00","170215"
|
||||||
|
"2013-09-01 00:00:00","165374"
|
||||||
|
"2013-10-01 00:00:00","184133"
|
||||||
|
"2013-11-01 00:00:00","176033"
|
||||||
|
"2013-12-01 00:00:00","165192"
|
||||||
|
"2014-01-01 00:00:00","188797"
|
||||||
|
"2014-02-01 00:00:00","187751"
|
||||||
|
"2014-03-01 00:00:00","207493"
|
||||||
|
"2014-04-01 00:00:00","194022"
|
||||||
|
"2014-05-01 00:00:00","176017"
|
||||||
|
"2014-06-01 00:00:00","162354"
|
||||||
|
"2014-07-01 00:00:00","177066"
|
||||||
|
"2014-08-01 00:00:00","163406"
|
||||||
|
"2014-09-01 00:00:00","165866"
|
||||||
|
"2014-10-01 00:00:00","172887"
|
||||||
|
"2014-11-01 00:00:00","166598"
|
||||||
|
"2014-12-01 00:00:00","155299"
|
||||||
|
"2015-01-01 00:00:00","165978"
|
||||||
|
"2015-02-01 00:00:00","168513"
|
||||||
|
"2015-03-01 00:00:00","189769"
|
||||||
|
"2015-04-01 00:00:00","189967"
|
||||||
|
"2015-05-01 00:00:00","185878"
|
||||||
|
"2015-06-01 00:00:00","187915"
|
||||||
|
"2015-07-01 00:00:00","195439"
|
||||||
|
"2015-08-01 00:00:00","181413"
|
||||||
|
"2015-09-01 00:00:00","177859"
|
||||||
|
"2015-10-01 00:00:00","186418"
|
||||||
|
"2015-11-01 00:00:00","178205"
|
||||||
|
"2015-12-01 00:00:00","172586"
|
||||||
|
"2016-01-01 00:00:00","181380"
|
||||||
|
"2016-02-01 00:00:00","188742"
|
||||||
|
"2016-03-01 00:00:00","201941"
|
||||||
|
"2016-04-01 00:00:00","197067"
|
||||||
|
"2016-05-01 00:00:00","189772"
|
||||||
|
"2016-06-01 00:00:00","184660"
|
||||||
|
"2016-07-01 00:00:00","176974"
|
||||||
|
"2016-08-01 00:00:00","181960"
|
||||||
|
"2016-09-01 00:00:00","171676"
|
||||||
|
"2016-10-01 00:00:00","175399"
|
||||||
|
"2016-11-01 00:00:00","175326"
|
||||||
|
"2016-12-01 00:00:00","162421"
|
||||||
|
"2017-01-01 00:00:00","176969"
|
||||||
|
"2017-02-01 00:00:00","175280"
|
||||||
|
"2017-03-01 00:00:00","201543"
|
||||||
|
"2017-04-01 00:00:00","178508"
|
||||||
|
"2017-05-01 00:00:00","186511"
|
||||||
|
"2017-06-01 00:00:00","178175"
|
||||||
|
"2017-07-01 00:00:00","179856"
|
||||||
|
"2017-08-01 00:00:00","177742"
|
||||||
|
"2017-09-01 00:00:00","162369"
|
||||||
|
"2017-10-01 00:00:00","170083"
|
||||||
|
"2017-11-01 00:00:00","169012"
|
||||||
|
"2017-12-01 00:00:00","145330"
|
||||||
|
"2018-01-01 00:00:00","160544"
|
||||||
|
"2018-02-01 00:00:00","153155"
|
||||||
|
"2018-03-01 00:00:00","173466"
|
||||||
|
"2018-04-01 00:00:00","162981"
|
||||||
|
"2018-05-01 00:00:00","168104"
|
||||||
|
"2018-06-01 00:00:00","154885"
|
||||||
|
"2018-07-01 00:00:00","160095"
|
||||||
|
"2018-08-01 00:00:00","158544"
|
||||||
|
"2018-09-01 00:00:00","144581"
|
||||||
|
"2018-10-01 00:00:00","160501"
|
||||||
|
"2018-11-01 00:00:00","149820"
|
||||||
|
"2018-12-01 00:00:00","132269"
|
||||||
|
"2019-01-01 00:00:00","149628"
|
||||||
|
"2019-02-01 00:00:00","146421"
|
||||||
|
"2019-03-01 00:00:00","161159"
|
||||||
|
"2019-04-01 00:00:00","153558"
|
||||||
|
"2019-05-01 00:00:00","151393"
|
||||||
|
"2019-06-01 00:00:00","135935"
|
||||||
|
"2019-07-01 00:00:00","151081"
|
||||||
|
"2019-08-01 00:00:00","137181"
|
||||||
|
"2019-09-01 00:00:00","136935"
|
||||||
|
"2019-10-01 00:00:00","152721"
|
||||||
|
"2019-11-01 00:00:00","148338"
|
||||||
|
"2019-12-01 00:00:00","132623"
|
||||||
|
"2020-01-01 00:00:00","146735"
|
||||||
|
"2020-02-01 00:00:00","145191"
|
||||||
|
"2020-03-01 00:00:00","156037"
|
||||||
|
"2020-04-01 00:00:00","183016"
|
||||||
|
"2020-05-01 00:00:00","186573"
|
||||||
|
"2020-06-01 00:00:00","172002"
|
||||||
|
"2020-07-01 00:00:00","166135"
|
||||||
|
"2020-08-01 00:00:00","148452"
|
||||||
|
"2020-09-01 00:00:00","141887"
|
||||||
|
"2020-10-01 00:00:00","141948"
|
||||||
|
"2020-11-01 00:00:00","135088"
|
||||||
|
"2020-12-01 00:00:00","134035"
|
||||||
|
"2021-01-01 00:00:00","140009"
|
||||||
|
"2021-02-01 00:00:00","131810"
|
||||||
|
"2021-03-01 00:00:00","148900"
|
||||||
|
"2021-04-01 00:00:00","136022"
|
||||||
|
"2021-05-01 00:00:00","133911"
|
||||||
|
"2021-06-01 00:00:00","129106"
|
||||||
|
"2021-07-01 00:00:00","124130"
|
||||||
|
"2021-08-01 00:00:00","122273"
|
||||||
|
"2021-09-01 00:00:00","119882"
|
||||||
|
"2021-10-01 00:00:00","119008"
|
||||||
|
"2021-11-01 00:00:00","119260"
|
||||||
|
"2021-12-01 00:00:00","112278"
|
||||||
|
"2022-01-01 00:00:00","119459"
|
||||||
|
"2022-02-01 00:00:00","114114"
|
||||||
|
"2022-03-01 00:00:00","123614"
|
||||||
|
"2022-04-01 00:00:00","114422"
|
||||||
|
"2022-05-01 00:00:00","116346"
|
||||||
|
"2022-06-01 00:00:00","111741"
|
||||||
|
"2022-07-01 00:00:00","111059"
|
||||||
|
"2022-08-01 00:00:00","113048"
|
||||||
|
"2022-09-01 00:00:00","103965"
|
||||||
|
"2022-10-01 00:00:00","106366"
|
||||||
|
"2022-11-01 00:00:00","109719"
|
||||||
|
"2022-12-01 00:00:00","96767"
|
||||||
|
"2023-01-01 00:00:00","97209"
|
||||||
|
"2023-02-01 00:00:00","85973"
|
||||||
|
"2023-03-01 00:00:00","87543"
|
||||||
|
"2023-04-01 00:00:00","68746"
|
||||||
|
"2023-05-01 00:00:00","66749"
|
||||||
|
"2023-06-01 00:00:00","63858"
|
||||||
|
"2023-07-01 00:00:00","62938"
|
||||||
|
"2023-08-01 00:00:00","60319"
|
||||||
|
"2023-09-01 00:00:00","53046"
|
||||||
|
"2023-10-01 00:00:00","52743"
|
||||||
|
"2023-11-01 00:00:00","50646"
|
||||||
|
"2023-12-01 00:00:00","42601"
|
||||||
|
"2024-01-01 00:00:00","47854"
|
||||||
|
"2024-02-01 00:00:00","46292"
|
||||||
|
"2024-03-01 00:00:00","45070"
|
||||||
|
"2024-04-01 00:00:00","42776"
|
||||||
|
"2024-05-01 00:00:00","40485"
|
||||||
|
"2024-06-01 00:00:00","32243"
|
||||||
|
"2024-07-01 00:00:00","31740"
|
||||||
|
"2024-08-01 00:00:00","28059"
|
||||||
|
"2024-09-01 00:00:00","24947"
|
||||||
|
"2024-10-01 00:00:00","23319"
|
||||||
|
"2024-11-01 00:00:00","20891"
|
||||||
|
"2024-12-01 00:00:00","18029"
|
||||||
|
"2025-01-01 00:00:00","22394"
|
||||||
|
"2025-02-01 00:00:00","19340"
|
||||||
|
"2025-03-01 00:00:00","18965"
|
||||||
|
"2025-04-01 00:00:00","14138"
|
||||||
|
"2025-05-01 00:00:00","11824"
|
||||||
|
"2025-06-01 00:00:00","9392"
|
||||||
|
"2025-07-01 00:00:00","7841"
|
||||||
|
"2025-08-01 00:00:00","5885"
|
||||||
|
"2025-09-01 00:00:00","6132"
|
||||||
|
"2025-10-01 00:00:00","5415"
|
||||||
|
"2025-11-01 00:00:00","4366"
|
||||||
|
"2025-12-01 00:00:00","3862"
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
Date,Total Issues
|
||||||
|
2008-07,0
|
||||||
|
2008-08,0
|
||||||
|
2008-09,0
|
||||||
|
2008-10,0
|
||||||
|
2008-11,0
|
||||||
|
2008-12,0
|
||||||
|
2009-01,0
|
||||||
|
2009-02,0
|
||||||
|
2009-03,0
|
||||||
|
2009-04,0
|
||||||
|
2009-05,0
|
||||||
|
2009-06,0
|
||||||
|
2009-07,0
|
||||||
|
2009-08,0
|
||||||
|
2009-09,0
|
||||||
|
2009-10,1
|
||||||
|
2009-11,199
|
||||||
|
2009-12,88
|
||||||
|
2010-01,57
|
||||||
|
2010-02,36
|
||||||
|
2010-03,42
|
||||||
|
2010-04,34
|
||||||
|
2010-05,45
|
||||||
|
2010-06,41
|
||||||
|
2010-07,54
|
||||||
|
2010-08,46
|
||||||
|
2010-09,41
|
||||||
|
2010-10,51
|
||||||
|
2010-11,35
|
||||||
|
2010-12,35
|
||||||
|
2011-01,50
|
||||||
|
2011-02,71
|
||||||
|
2011-03,42
|
||||||
|
2011-04,59
|
||||||
|
2011-05,73
|
||||||
|
2011-06,70
|
||||||
|
2011-07,50
|
||||||
|
2011-08,49
|
||||||
|
2011-09,46
|
||||||
|
2011-10,48
|
||||||
|
2011-11,52
|
||||||
|
2011-12,60
|
||||||
|
2012-01,81
|
||||||
|
2012-02,148
|
||||||
|
2012-03,112
|
||||||
|
2012-04,54
|
||||||
|
2012-05,46
|
||||||
|
2012-06,38
|
||||||
|
2012-07,47
|
||||||
|
2012-08,40
|
||||||
|
2012-09,63
|
||||||
|
2012-10,56
|
||||||
|
2012-11,72
|
||||||
|
2012-12,62
|
||||||
|
2013-01,47
|
||||||
|
2013-02,70
|
||||||
|
2013-03,70
|
||||||
|
2013-04,76
|
||||||
|
2013-05,75
|
||||||
|
2013-06,93
|
||||||
|
2013-07,75
|
||||||
|
2013-08,97
|
||||||
|
2013-09,90
|
||||||
|
2013-10,78
|
||||||
|
2013-11,85
|
||||||
|
2013-12,98
|
||||||
|
2014-01,112
|
||||||
|
2014-02,105
|
||||||
|
2014-03,152
|
||||||
|
2014-04,135
|
||||||
|
2014-05,123
|
||||||
|
2014-06,141
|
||||||
|
2014-07,138
|
||||||
|
2014-08,157
|
||||||
|
2014-09,211
|
||||||
|
2014-10,269
|
||||||
|
2014-11,272
|
||||||
|
2014-12,348
|
||||||
|
2015-01,391
|
||||||
|
2015-02,452
|
||||||
|
2015-03,495
|
||||||
|
2015-04,666
|
||||||
|
2015-05,739
|
||||||
|
2015-06,875
|
||||||
|
2015-07,1024
|
||||||
|
2015-08,838
|
||||||
|
2015-09,710
|
||||||
|
2015-10,839
|
||||||
|
2015-11,1287
|
||||||
|
2015-12,1222
|
||||||
|
2016-01,1326
|
||||||
|
2016-02,1576
|
||||||
|
2016-03,1490
|
||||||
|
2016-04,1404
|
||||||
|
2016-05,1456
|
||||||
|
2016-06,1502
|
||||||
|
2016-07,1647
|
||||||
|
2016-08,1765
|
||||||
|
2016-09,1796
|
||||||
|
2016-10,1542
|
||||||
|
2016-11,1473
|
||||||
|
2016-12,1465
|
||||||
|
2017-01,1551
|
||||||
|
2017-02,1725
|
||||||
|
2017-03,2055
|
||||||
|
2017-04,1847
|
||||||
|
2017-05,2063
|
||||||
|
2017-06,2057
|
||||||
|
2017-07,1805
|
||||||
|
2017-08,1973
|
||||||
|
2017-09,2051
|
||||||
|
2017-10,2115
|
||||||
|
2017-11,2510
|
||||||
|
2017-12,2353
|
||||||
|
2018-01,3100
|
||||||
|
2018-02,3097
|
||||||
|
2018-03,3790
|
||||||
|
2018-04,3662
|
||||||
|
2018-05,3875
|
||||||
|
2018-06,4247
|
||||||
|
2018-07,4377
|
||||||
|
2018-08,4741
|
||||||
|
2018-09,4459
|
||||||
|
2018-10,5292
|
||||||
|
2018-11,5458
|
||||||
|
2018-12,6048
|
||||||
|
2019-01,8605
|
||||||
|
2019-02,8408
|
||||||
|
2019-03,9698
|
||||||
|
2019-04,8991
|
||||||
|
2019-05,9687
|
||||||
|
2019-06,9013
|
||||||
|
2019-07,9963
|
||||||
|
2019-08,10381
|
||||||
|
2019-09,9459
|
||||||
|
2019-10,10923
|
||||||
|
2019-11,10365
|
||||||
|
2019-12,9743
|
||||||
|
2020-01,10240
|
||||||
|
2020-02,9782
|
||||||
|
2020-03,11336
|
||||||
|
2020-04,12978
|
||||||
|
2020-05,12955
|
||||||
|
2020-06,11921
|
||||||
|
2020-07,11547
|
||||||
|
2020-08,11006
|
||||||
|
2020-09,10252
|
||||||
|
2020-10,10560
|
||||||
|
2020-11,9827
|
||||||
|
2020-12,9509
|
||||||
|
2021-01,9788
|
||||||
|
2021-02,10228
|
||||||
|
2021-03,11503
|
||||||
|
2021-04,10537
|
||||||
|
2021-05,10048
|
||||||
|
2021-06,10273
|
||||||
|
2021-07,9494
|
||||||
|
2021-08,9496
|
||||||
|
2021-09,9584
|
||||||
|
2021-10,9652
|
||||||
|
2021-11,9500
|
||||||
|
2021-12,8836
|
||||||
|
2022-01,9202
|
||||||
|
2022-02,9612
|
||||||
|
2022-03,10661
|
||||||
|
2022-04,9016
|
||||||
|
2022-05,9552
|
||||||
|
2022-06,9207
|
||||||
|
2022-07,9633
|
||||||
|
2022-08,10136
|
||||||
|
2022-09,11260
|
||||||
|
2022-10,11668
|
||||||
|
2022-11,10974
|
||||||
|
2022-12,9605
|
||||||
|
2023-01,11124
|
||||||
|
2023-02,10993
|
||||||
|
2023-03,12906
|
||||||
|
2023-04,12436
|
||||||
|
2023-05,12798
|
||||||
|
2023-06,11041
|
||||||
|
2023-07,11009
|
||||||
|
2023-08,11467
|
||||||
|
2023-09,10751
|
||||||
|
2023-10,11316
|
||||||
|
2023-11,11278
|
||||||
|
2023-12,9598
|
||||||
|
2024-01,10656
|
||||||
|
2024-02,10658
|
||||||
|
2024-03,10949
|
||||||
|
2024-04,10735
|
||||||
|
2024-05,10996
|
||||||
|
2024-06,10195
|
||||||
|
2024-07,11183
|
||||||
|
2024-08,11632
|
||||||
|
2024-09,11180
|
||||||
|
2024-10,11319
|
||||||
|
2024-11,10850
|
||||||
|
2024-12,10091
|
||||||
|
2025-01,11259
|
||||||
|
2025-02,12612
|
||||||
|
2025-03,13429
|
||||||
|
2025-04,11680
|
||||||
|
2025-05,11367
|
||||||
|
2025-06,10888
|
||||||
|
2025-07,14019
|
||||||
|
2025-08,13127
|
||||||
|
2025-09,12489
|
||||||
|
2025-10,13904
|
||||||
|
2025-11,13730
|
||||||
|
2025-12,12652
|
||||||
|
11
content/guestbook.md
Normal file
11
content/guestbook.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
title: "Guestbook"
|
||||||
|
author: Dan
|
||||||
|
type: guestbook
|
||||||
|
date: 2026-01-11
|
||||||
|
comments: false
|
||||||
|
---
|
||||||
|
|
||||||
|
I'd love to hear from you if you've passed by my corner of the internet.
|
||||||
|
|
||||||
|
If you want to say hi privately you can [email me](mailto:dan@ritual.sh) instead.
|
||||||
BIN
content/media/fackham-hall/cover.jpg
Normal file
BIN
content/media/fackham-hall/cover.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
13
content/media/fackham-hall/index.md
Normal file
13
content/media/fackham-hall/index.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
title: "Fackham Hall"
|
||||||
|
date: 2025-12-24
|
||||||
|
tags: ["film"] # album, film, show, book, game
|
||||||
|
format: "stream" # stream, cd, vinyl, cassette, dvd, bluray, digital
|
||||||
|
description: ""
|
||||||
|
cover: "cover.jpg"
|
||||||
|
rating: 8 # out of 10
|
||||||
|
build:
|
||||||
|
render: never
|
||||||
|
---
|
||||||
|
|
||||||
|
Review here :)
|
||||||
BIN
content/media/taskmaster-champion-of-champions-4/canvas.png
Normal file
BIN
content/media/taskmaster-champion-of-champions-4/canvas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
content/media/taskmaster-champion-of-champions-4/cover.jpg
Normal file
BIN
content/media/taskmaster-champion-of-champions-4/cover.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
13
content/media/taskmaster-champion-of-champions-4/index.md
Normal file
13
content/media/taskmaster-champion-of-champions-4/index.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
title: "Taskmaster - Champion of Champions 4"
|
||||||
|
date: 2025-12-26
|
||||||
|
tags: ["show"] # album, film, show, book, game
|
||||||
|
format: "stream" # stream, cd, vinyl, cassette, dvd, bluray, digital
|
||||||
|
description: ""
|
||||||
|
cover: "cover.jpg"
|
||||||
|
rating: 9 # out of 10
|
||||||
|
build:
|
||||||
|
render: never
|
||||||
|
---
|
||||||
|
|
||||||
|
Review here :)
|
||||||
|
|
@ -7,13 +7,13 @@ order: 4
|
||||||
|
|
||||||
**Software:**
|
**Software:**
|
||||||
|
|
||||||
- **Browser** - [Zen](https://zen-browser.app/)
|
- **Browser** - [Zen](https://zen-browser.app/)
|
||||||
- **Code Editor** - VS Code
|
- **Code Editor** - VS Code
|
||||||
- **Audio** - High Tide & Turntable
|
- **Audio** - High Tide & Turntable
|
||||||
- **Password Manager** - 1password
|
- **Password Manager** - [Proton Pass](https://pr.tn/ref/MNB13JYX)
|
||||||
|
|
||||||
**Hardware:**
|
**Hardware:**
|
||||||
|
|
||||||
- **Keyboard** - Ducky One 3 Classic 65% w/ Cherry Red switches
|
- **Keyboard** - Ducky One 3 Classic 65% w/ Cherry Red switches
|
||||||
- **Mouse** - Razer Naga X
|
- **Mouse** - Razer Naga X
|
||||||
- **Microphone** - Marantz MPM-1000
|
- **Microphone** - Marantz MPM-1000
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
---
|
---
|
||||||
title: "Resources"
|
title: "Resources"
|
||||||
description: "A collection of useful tools, scripts, and experiments"
|
|
||||||
draft: false
|
draft: false
|
||||||
---
|
---
|
||||||
|
|
||||||
Welcome to my whiteboard of resources. Here you'll find various tools, scripts, and experiments I've built and wanted to share.
|
Welcome to my test chamber, here you'll find various experiments, tests and resources. Take a look around.
|
||||||
|
|
||||||
|
The cake isn't a lie.
|
||||||
|
|
|
||||||
74
content/resources/adoptables/index.md
Normal file
74
content/resources/adoptables/index.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
---
|
||||||
|
title: "Adoptables"
|
||||||
|
date: 2026-01-10
|
||||||
|
description: "Customisable widgets and animations for your website"
|
||||||
|
icon: "adoptables"
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
Welcome to my adoptables collection! These are customisable widgets you can embed on your own website. Each adoptable comes with a live preview and customisation options. Just configure it how you like, grab the code, and paste it into your site.
|
||||||
|
|
||||||
|
All adoptables are:
|
||||||
|
|
||||||
|
- **Free to use** - No attribution required (but appreciated!)
|
||||||
|
- **Probably customisable** - Tweak your own colors and settings if applicable
|
||||||
|
- **Self-contained** - Single script tag, no dependencies
|
||||||
|
- **Responsive** - Scales to fit your layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lava Lamp
|
||||||
|
|
||||||
|
My brother had a blue and yellow lava lamp when I was younger and I was obsessed with it, so making one for my website was an obvious choice... and now I'd like to share it with everyone!
|
||||||
|
|
||||||
|
Using the pixelation effect with very high contrast colours is going to result in an aura on the blobs due to the way the SVG filter works, I am trying to find a solution for this.
|
||||||
|
|
||||||
|
{{< lavalamp-adoptable >}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
1. **Customise** - Use the controls to adjust colors, animation speed, and other settings
|
||||||
|
2. **Get Code** - Click "Get Embed Code" to generate your custom script tag
|
||||||
|
3. **Embed** - Copy the code and paste it anywhere in your HTML
|
||||||
|
4. **Style** - Wrap it in a div and set width/height to control the lamp size
|
||||||
|
|
||||||
|
### Example Container
|
||||||
|
|
||||||
|
The lava lamp will scale to fit its container. You can control the size like this:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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!
|
||||||
40
content/resources/button-generator/index.md
Normal file
40
content/resources/button-generator/index.md
Normal file
|
|
@ -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!
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
title: "Last.fm Weekly Stats Script"
|
title: "Last.fm Weekly Stats"
|
||||||
date: 2026-01-04
|
date: 2026-01-04
|
||||||
tags: ["javascript", "api", "last.fm"]
|
|
||||||
description: "Fetch and display your weekly listening stats from Last.fm"
|
description: "Fetch and display your weekly listening stats from Last.fm"
|
||||||
icon: "lastfm-stats"
|
icon: "lastfm-stats"
|
||||||
demo_url: ""
|
demo_url: ""
|
||||||
|
|
@ -9,26 +8,115 @@ source_url: ""
|
||||||
draft: false
|
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
|
{{< lastfm-stats-form >}}
|
||||||
- Displays track count and listening time
|
|
||||||
- Formats data in a clean, readable format
|
|
||||||
- Easy to integrate into blogs or dashboards
|
|
||||||
|
|
||||||
## Setup
|
## Download the Shell Script
|
||||||
|
|
||||||
1. Get your Last.fm API key from [Last.fm API](https://www.last.fm/api)
|
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.
|
||||||
2. Configure your username in the script
|
|
||||||
3. Run the script to fetch your weekly stats
|
|
||||||
|
|
||||||
## Output
|
```
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
The script returns your top tracks for the week, including:
|
# Last.fm Weekly Stats Script
|
||||||
- Track name and artist
|
# Fetches your Last.fm listening statistics for the past week
|
||||||
- Play count
|
#
|
||||||
- Listening duration
|
# 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!
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
10
content/updates/2026-01-08-resources-section.md
Normal file
10
content/updates/2026-01-08-resources-section.md
Normal file
|
|
@ -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
|
||||||
10
content/updates/2026-01-11-button-generator.md
Normal file
10
content/updates/2026-01-11-button-generator.md
Normal file
|
|
@ -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!
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Last.fm Weekly Stats Script
|
# 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
|
# Load environment variables from .env file
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
|
||||||
<meta name="theme-color" content="#00ff00" />
|
<meta name="theme-color" content="#00ff00" />
|
||||||
|
<link rel="webmention" href="https://api.ritual.sh/webmention" />
|
||||||
<title>
|
<title>
|
||||||
{{ block "title" . }}{{ .Site.Title }} {{ with .Params.Title }} | {{ .
|
{{ block "title" . }}{{ .Site.Title }} {{ with .Params.Title }} | {{ .
|
||||||
}}{{ end }}{{ end }}
|
}}{{ end }}{{ end }}
|
||||||
|
|
@ -17,6 +17,16 @@
|
||||||
<link rel="stylesheet" href="{{ relURL ($.Site.BaseURL) }}{{ . }}" />
|
<link rel="stylesheet" href="{{ relURL ($.Site.BaseURL) }}{{ . }}" />
|
||||||
{{ end }} {{ block "favicon" . }} {{ partial "site-favicon.html" . }} {{ end
|
{{ 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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -24,5 +34,23 @@
|
||||||
<main role="main">{{ block "main" . }}{{ end }}</main>
|
<main role="main">{{ block "main" . }}{{ end }}</main>
|
||||||
{{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }} {{ block
|
{{ block "footer" . }}{{ partial "site-footer.html" . }}{{ end }} {{ block
|
||||||
"scripts" . }}{{ partial "site-scripts.html" . }}{{ end }}
|
"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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
36
layouts/_default/list.webmentions.json
Normal file
36
layouts/_default/list.webmentions.json
Normal file
|
|
@ -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 -}}
|
||||||
|
]
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
{{ define "header" }}{{ partial "page-header.html" . }}{{ end }} {{ define
|
{{ define "main" }}
|
||||||
"main" }}
|
|
||||||
<article class="about-page">
|
<article class="about-page">
|
||||||
<div class="about-content">
|
<div class="about-content">
|
||||||
<div class="content-screen">
|
<div class="content-screen">
|
||||||
<div class="about-header">{{ partial "elements/lavalamp.html" . }}</div>
|
<div class="about-header">{{ partial "elements/lavalamp.html" . }}</div>
|
||||||
<div class="screen-display crt no-scroll">
|
<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>Name</span> / <span class="info">Dan (He/Him)</span><br />
|
||||||
<span>Age</span> / <span class="info">40-something</span><br />
|
<span>Age</span> / <span class="info">40-something</span><br />
|
||||||
<span>Location</span> / <span class="info">UK 🇬🇧</span><br /><br />
|
<span>Location</span> / <span class="info">UK 🇬🇧</span><br /><br />
|
||||||
<span>Interests</span> /
|
<span>Interests</span> /
|
||||||
<span class="info">
|
<span class="info"> Programming. Music. Movies. Tech. Photography.</span
|
||||||
Programming. Music. Movies. Tech. Photography. </span
|
|
||||||
><br />
|
><br />
|
||||||
<span class="cursor-blink">_</span>
|
<span class="cursor-blink">_</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -23,6 +21,53 @@
|
||||||
partial "elements/lcd-screen.html" (dict "text" .) }} {{ end }}
|
partial "elements/lcd-screen.html" (dict "text" .) }} {{ end }}
|
||||||
</div>
|
</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="wide-item manifesto-container">
|
||||||
<div class="content-screen">
|
<div class="content-screen">
|
||||||
<div class="screen-display crt">
|
<div class="screen-display crt">
|
||||||
|
|
@ -105,10 +150,96 @@
|
||||||
><br />
|
><br />
|
||||||
Camera / <span class="info">Fuji X-T5</span><br />
|
Camera / <span class="info">Fuji X-T5</span><br />
|
||||||
Audio / <span class="info">Hiby R4 EVA, Fiio FT-1</span> <br /><br />
|
Audio / <span class="info">Hiby R4 EVA, Fiio FT-1</span> <br /><br />
|
||||||
More info coming soon...
|
Check out my <a href="/now">/now</a> page to see more hardware and
|
||||||
<br /><span class="cursor-blink">_</span>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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 }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
<div class="blog-summary">{{ .Content }}</div>
|
<div class="blog-summary">{{ .Content }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{{ partial "contact-section.html" . }}
|
||||||
|
|
||||||
<nav class="blog-post-navigation">
|
<nav class="blog-post-navigation">
|
||||||
<div class="post-nav-links">
|
<div class="post-nav-links">
|
||||||
{{ with .PrevInSection }}
|
{{ with .PrevInSection }}
|
||||||
|
|
@ -46,4 +48,7 @@
|
||||||
<div class="blogs-lavalamp">{{ partial "elements/lavalamp.html" . }}</div>
|
<div class="blogs-lavalamp">{{ partial "elements/lavalamp.html" . }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/footnote-scroll.js"></script>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
112
layouts/guestbook/single.html
Normal file
112
layouts/guestbook/single.html
Normal file
|
|
@ -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 }}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* TEAM */
|
/* TEAM */
|
||||||
Creator: Dan
|
Creator: Dan Baker
|
||||||
Contact: dan [at] unbo.lt
|
Contact: dan [at] ritual.sh
|
||||||
From: ritual.sh
|
From: ritual.sh
|
||||||
|
|
||||||
/* THANKS */
|
/* THANKS */
|
||||||
|
|
@ -16,7 +16,6 @@ Software: Built with care
|
||||||
|
|
||||||
/* PHILOSOPHY */
|
/* PHILOSOPHY */
|
||||||
No tracking
|
No tracking
|
||||||
No analytics
|
|
||||||
No AI training data
|
No AI training data
|
||||||
No engagement metrics
|
No engagement metrics
|
||||||
No corporate platforms
|
No corporate platforms
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="window hidden-lg-down">{{ partial "elements/window.html" . }}</div>
|
<div class="window hidden-lg-down">{{ partial "elements/window.html" . }}</div>
|
||||||
|
|
||||||
<!-- Neon sign above monitor -->
|
<!-- Neon sign above monitor -->
|
||||||
{{ partial "elements/neon-sign.html" . }}
|
<div class="homepage-neon">{{ partial "elements/neon-sign.html" . }}</div>
|
||||||
|
|
||||||
<!-- Sticky notes -->
|
<!-- Sticky notes -->
|
||||||
<div class="sticky-note note1 hidden-xl-down">fix bugs</div>
|
<div class="sticky-note note1 hidden-xl-down">fix bugs</div>
|
||||||
|
|
@ -89,18 +89,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="secondary-screen wall-monitor-4 hidden-xl-down">
|
<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 />
|
> 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>
|
<span class="cursor-blink">_</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -239,7 +229,7 @@
|
||||||
<div>
|
<div>
|
||||||
<a href="/audio/">
|
<a href="/audio/">
|
||||||
<div class="music">
|
<div class="music">
|
||||||
<div class="music-text">MUSIC & AUDIO GEAR</div>
|
<div class="music-text">Music & Audio Gear</div>
|
||||||
|
|
||||||
{{ partial "elements/ipod.html" . }}
|
{{ partial "elements/ipod.html" . }}
|
||||||
|
|
||||||
|
|
@ -265,6 +255,26 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>{{ partial "elements/crt-tv.html" . }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,49 +14,63 @@
|
||||||
▀██▀ ▀██▄▄▀█▄▄▄▄█▀███▄██▄▀█▄██ ████████▄▀███▀ ▀████
|
▀██▀ ▀██▄▄▀█▄▄▄▄█▀███▄██▄▀█▄██ ████████▄▀███▀ ▀████
|
||||||
██
|
██
|
||||||
▀▀▀
|
▀▀▀
|
||||||
</pre>
|
</pre
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="media-layout">
|
<div class="media-layout">
|
||||||
<div class="media-list">
|
<div class="media-list">
|
||||||
|
<h3>Recent Media</h3>
|
||||||
{{ range .Paginator.Pages }}
|
{{ range .Paginator.Pages }}
|
||||||
<div class="media-item" data-type="{{ index .Params.tags 0 }}">
|
<div class="media-item" data-type="{{ index .Params.tags 0 }}">
|
||||||
<div class="media-cover">
|
<div class="media-cover">
|
||||||
{{ with .Resources.GetMatch "cover.*" }}
|
{{ with .Resources.GetMatch "cover.*" }} {{ $image := .Resize
|
||||||
{{ $image := .Resize "320x webp q85" }}
|
"320x webp q85" }}
|
||||||
<img src="{{ $image.RelPermalink }}" alt="{{ $.Title }}" loading="lazy" width="{{ $image.Width }}" height="{{ $image.Height }}">
|
<img
|
||||||
{{ else }}
|
src="{{ $image.RelPermalink }}"
|
||||||
<div class="no-cover">
|
alt="{{ $.Title }}"
|
||||||
<span class="cover-placeholder">NO COVER</span>
|
loading="lazy"
|
||||||
</div>
|
width="{{ $image.Width }}"
|
||||||
{{ end }}
|
height="{{ $image.Height }}"
|
||||||
</div>
|
/>
|
||||||
<div class="media-info">
|
{{ else }}
|
||||||
<div class="media-meta">
|
<div class="no-cover">
|
||||||
<div class="media-type">{{ index .Params.tags 0 | upper }}</div>
|
<span class="cover-placeholder">NO COVER</span>
|
||||||
{{ 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 }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
<div class="media-info">
|
||||||
|
<div class="media-meta">
|
||||||
{{ partial "pagination.html" .Paginator }}
|
<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>
|
||||||
|
|
||||||
<div class="lastfm-sidebar">
|
<div class="lastfm-sidebar">
|
||||||
<div class="lastfm-header">
|
<div class="lastfm-header">
|
||||||
<h3>Recently Listened</h3>
|
<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>
|
||||||
<div class="lastfm-tracks" id="lastfm-tracks">
|
<div class="lastfm-tracks" id="lastfm-tracks">
|
||||||
<div class="lastfm-loading">Loading tracks...</div>
|
<div class="lastfm-loading">Loading tracks...</div>
|
||||||
|
|
@ -67,6 +81,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/pages/media.js"></script>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
27
layouts/partials/contact-section.html
Normal file
27
layouts/partials/contact-section.html
Normal file
|
|
@ -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>
|
||||||
41
layouts/partials/elements/companion-cube.html
Normal file
41
layouts/partials/elements/companion-cube.html
Normal file
|
|
@ -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>
|
||||||
|
|
@ -1,15 +1,3 @@
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>{{ partial "site-navigation.html" . }}</div>
|
||||||
{{ partial "site-navigation.html" . }}
|
</header>
|
||||||
<div>
|
|
||||||
<!-- <h1>
|
|
||||||
{{ .Title | default .Site.Title }}
|
|
||||||
</h1> -->
|
|
||||||
{{ with .Params.description }}
|
|
||||||
<h2>
|
|
||||||
{{ . }}
|
|
||||||
</h2>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,18 @@
|
||||||
{{ $filtered := slice }}
|
{{ $filtered := slice }}
|
||||||
{{ range $remaining }}
|
{{ range $remaining }}
|
||||||
{{ $path := .RelPermalink }}
|
{{ $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 . }}
|
{{ $filtered = $filtered | append . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ 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 }}
|
{{ $terminalBundle := $allFiles | resources.Concat "js/terminal-bundle.js" | resources.Minify | resources.Fingerprint }}
|
||||||
<!-- prettier-ignore-end -->
|
<!-- prettier-ignore-end -->
|
||||||
<script
|
<script
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,39 @@
|
||||||
{{ define "main" }}
|
{{ 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="resources-container">
|
||||||
<div class="whiteboard">
|
<div class="portal-header">
|
||||||
<div class="whiteboard-header">
|
<div class="portal-icon blue-portal hidden-lg-down"></div>
|
||||||
<h1>{{ .Title }}</h1>
|
<h1 class="portal-title">Resources</h1>
|
||||||
<div class="whiteboard-description">
|
<div class="portal-icon orange-portal hidden-md-down"></div>
|
||||||
{{ .Content }}
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="whiteboard-pins">
|
|
||||||
{{ range .Pages }}
|
|
||||||
{{ .Render "summary" }}
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="background-cube">
|
||||||
|
{{ partial "elements/companion-cube.html" . }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
<div class="resource-single">
|
<div class="resource-single">
|
||||||
|
<div class="test-chamber-grid">
|
||||||
|
</div>
|
||||||
|
|
||||||
<article class="resource-content">
|
<article class="resource-content">
|
||||||
|
<!-- Aperture Science hazard stripes in corners -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Animated energy particles -->
|
||||||
|
<div class="energy-particles"></div>
|
||||||
|
|
||||||
<header class="resource-header">
|
<header class="resource-header">
|
||||||
<div class="resource-icon-large {{ .Params.icon }}"></div>
|
|
||||||
<h1>{{ .Title }}</h1>
|
<h1>{{ .Title }}</h1>
|
||||||
{{ if .Params.description }}
|
{{ if .Params.description }}
|
||||||
<p class="lead">{{ .Params.description }}</p>
|
<p class="lead">{{ .Params.description }}</p>
|
||||||
|
|
@ -28,6 +36,8 @@
|
||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ partial "contact-section.html" . }}
|
||||||
|
|
||||||
<nav class="resource-navigation">
|
<nav class="resource-navigation">
|
||||||
{{ with .PrevInSection }}
|
{{ with .PrevInSection }}
|
||||||
<a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a>
|
<a href="{{ .Permalink }}" class="nav-link prev">← {{ .Title }}</a>
|
||||||
|
|
@ -38,5 +48,8 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
</article>
|
</article>
|
||||||
|
<div class="background-cube">
|
||||||
|
{{ partial "elements/companion-cube.html" . }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,8 @@
|
||||||
<div class="resource-pin" data-icon="{{ .Params.icon }}">
|
<div class="resource-pin" data-icon="{{ .Params.icon }}">
|
||||||
<div class="pin-tack"></div>
|
<a href="{{ .Permalink }}">
|
||||||
<div class="resource-card">
|
<div class="resource-card">
|
||||||
<div class="resource-icon {{ .Params.icon }}"></div>
|
<div class="resource-icon {{ .Params.icon }}"></div>
|
||||||
<div class="resource-info">
|
<div class="resource-info">{{ .Title }}</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
22
layouts/shortcodes/button-generator.html
Normal file
22
layouts/shortcodes/button-generator.html
Normal file
|
|
@ -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>
|
||||||
350
layouts/shortcodes/graph.html
Normal file
350
layouts/shortcodes/graph.html
Normal file
|
|
@ -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>
|
||||||
53
layouts/shortcodes/lastfm-stats-form.html
Normal file
53
layouts/shortcodes/lastfm-stats-form.html
Normal file
|
|
@ -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>
|
||||||
124
layouts/shortcodes/lavalamp-adoptable.html
Normal file
124
layouts/shortcodes/lavalamp-adoptable.html
Normal file
|
|
@ -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>
|
||||||
96
send-webmentions.sh
Executable file
96
send-webmentions.sh
Executable file
|
|
@ -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"
|
||||||
364
static/js/adoptables/lavalamp.js
Normal file
364
static/js/adoptables/lavalamp.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
434
static/js/button-generator/button-generator-core.js
Normal file
434
static/js/button-generator/button-generator-core.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
263
static/js/button-generator/color-quantizer.js
Normal file
263
static/js/button-generator/color-quantizer.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
static/js/button-generator/debug-helper.js
Normal file
25
static/js/button-generator/debug-helper.js
Normal file
|
|
@ -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 ===');
|
||||||
|
}
|
||||||
92
static/js/button-generator/effect-base.js
Normal file
92
static/js/button-generator/effect-base.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
205
static/js/button-generator/effects/EXAMPLE.js
Normal file
205
static/js/button-generator/effects/EXAMPLE.js
Normal file
|
|
@ -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
|
||||||
|
*/
|
||||||
191
static/js/button-generator/effects/background-aurora.js
Normal file
191
static/js/button-generator/effects/background-aurora.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
178
static/js/button-generator/effects/background-bubbles.js
Normal file
178
static/js/button-generator/effects/background-bubbles.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
109
static/js/button-generator/effects/background-emoji-wallpaper.js
Normal file
109
static/js/button-generator/effects/background-emoji-wallpaper.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
311
static/js/button-generator/effects/background-external-image.js
Normal file
311
static/js/button-generator/effects/background-external-image.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
216
static/js/button-generator/effects/background-fire.js
Normal file
216
static/js/button-generator/effects/background-fire.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
76
static/js/button-generator/effects/background-gradient.js
Normal file
76
static/js/button-generator/effects/background-gradient.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
139
static/js/button-generator/effects/background-rain.js
Normal file
139
static/js/button-generator/effects/background-rain.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
137
static/js/button-generator/effects/background-rainbow.js
Normal file
137
static/js/button-generator/effects/background-rainbow.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
57
static/js/button-generator/effects/background-solid.js
Normal file
57
static/js/button-generator/effects/background-solid.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
187
static/js/button-generator/effects/background-starfield.js
Normal file
187
static/js/button-generator/effects/background-starfield.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
217
static/js/button-generator/effects/background-texture.js
Normal file
217
static/js/button-generator/effects/background-texture.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
401
static/js/button-generator/effects/border.js
Normal file
401
static/js/button-generator/effects/border.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
93
static/js/button-generator/effects/glitch.js
Normal file
93
static/js/button-generator/effects/glitch.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
170
static/js/button-generator/effects/hologram.js
Normal file
170
static/js/button-generator/effects/hologram.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
68
static/js/button-generator/effects/noise.js
Normal file
68
static/js/button-generator/effects/noise.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
62
static/js/button-generator/effects/pulse.js
Normal file
62
static/js/button-generator/effects/pulse.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
99
static/js/button-generator/effects/rainbow-text.js
Normal file
99
static/js/button-generator/effects/rainbow-text.js
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
85
static/js/button-generator/effects/rgb-split.js
Normal file
85
static/js/button-generator/effects/rgb-split.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
72
static/js/button-generator/effects/rotate.js
Normal file
72
static/js/button-generator/effects/rotate.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
79
static/js/button-generator/effects/scanline.js
Normal file
79
static/js/button-generator/effects/scanline.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
57
static/js/button-generator/effects/shimmer.js
Normal file
57
static/js/button-generator/effects/shimmer.js
Normal file
|
|
@ -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());
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue