feat(themes): Community Themes

This commit is contained in:
Samidy 2026-02-24 01:57:17 +03:00
parent 77d99245c8
commit 29b4899089
6 changed files with 1086 additions and 0 deletions

77
THEME_GUIDE.md Normal file
View file

@ -0,0 +1,77 @@
# Monochrome Theme Creation Guide
Welcome to the Monochrome Theme Guide! This document explains how to create, style, and upload custom themes for Monochrome.
## Getting Started
Themes in Monochrome are essentially CSS snippets that override the default CSS variables (custom properties). You can create a theme by defining these variables inside a `:root` block.
### Basic Structure
```css
:root {
/* Base Colors */
--background: #0a0a0a;
--foreground: #ededed;
/* UI Elements */
--card: #1a1a1a;
--card-foreground: #ededed;
--border: #2a2a2a;
/* Accents */
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #2a2a2a;
--secondary-foreground: #ededed;
/* Text */
--muted: #2a2a2a;
--muted-foreground: #a0a0a0;
/* Special */
--highlight: #3b82f6;
--ring: #3b82f6;
--radius: 8px;
--font-family: 'Inter', sans-serif;
}
```
## CSS Variables Reference
| Variable | Description |
| :--- | :--- |
| `--background` | The main background color for your theme. |
| `--foreground` | The main text color. |
| `--card` | Background color for cards, modals, and panels. |
| `--card-foreground` | Text color inside cards. |
| `--border` | Color for borders and separators. |
| `--primary` | Main accent color (buttons, active states). |
| `--primary-foreground` | Text color on top of primary elements. |
| `--secondary` | Secondary background (hover states, secondary buttons). |
| `--secondary-foreground` | Text color on secondary elements. |
| `--muted` | Muted background color (placeholders, skeletons). |
| `--muted-foreground` | Muted text color (subtitles, metadata). |
| `--highlight` | Color used for text highlighting and focus rings. |
| `--radius` | Border radius for cards and buttons (e.g., `8px`, `0px`). |
| `--font-family` | Font stack for the theme. |
## Using the Theme Editor
1. **Open the Theme Store**: Go to Settings > Appearance > Open Theme Store.
2. **Go to Upload Tab**: Click on the "Upload" tab.
3. **Use the Toolbar**:
* **Colors**: Use the color pickers to quickly set the main colors.
* **Styles**: Use the dropdowns to set font and border radius.
* **Template**: Click "Template" to insert a starter CSS block.
* **Preview**: Click "Preview" to see your changes in real-time on a sample card.
4. **Manual Editing**: You can manually edit the CSS in the text area for fine-grained control.
## Uploading Your Theme
1. **Name & Description**: Give your theme a unique name and a brief description.
2. **Author Website**: Optionally provide a link to your website.
* *Note:* If you have a Monochrome profile, your name will automatically link to it.
3. **Submit**: Click "Upload Theme".
Once uploaded, your theme will be available for everyone to browse and apply!

View file

@ -1258,6 +1258,129 @@
</div>
</div>
<div id="theme-store-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content wide" style="height: 80vh; display: flex; flex-direction: column; max-height: 80vh;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">Theme Store</h3>
<button class="close-modal-btn" style="background:none; border:none; font-size:1.5rem; cursor:pointer; color:var(--muted-foreground);">&times;</button>
</div>
<div class="search-tabs" style="margin-bottom: 1rem;">
<button class="search-tab active" data-tab="browse">Browse</button>
<button class="search-tab" data-tab="upload">Upload</button>
</div>
<div id="theme-store-details" style="display: none; flex-direction: column; height: 100%; overflow: hidden;">
<button class="btn-secondary" id="theme-details-back-btn" style="align-self: flex-start; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<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"><path d="m15 18-6-6 6-6"/></svg>
Back
</button>
<div style="flex: 1; overflow-y: auto; padding-right: 0.5rem;">
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 2rem;">
<div id="theme-details-preview-container" style="width: 300px; height: 220px; flex-shrink: 0; border: 1px solid var(--border); border-radius: var(--radius);"></div>
<div style="flex: 1; min-width: 250px;">
<h2 id="theme-details-name" style="font-size: 2rem; margin-bottom: 0.5rem;"></h2>
<div class="meta" style="color: var(--muted-foreground); font-size: 0.9rem; margin-bottom: 1rem; line-height: 1.6;">
<div id="theme-details-author"></div>
<div>Created: <span id="theme-details-created"></span> - Updated: <span id="theme-details-updated"></span></div>
<div>Installs: <span id="theme-details-installs">0</span></div>
</div>
<button id="theme-details-apply-btn" class="btn-primary" style="width: 100%; max-width: 200px;">Apply Theme</button>
</div>
</div>
<div style="margin-bottom: 2rem;">
<h3 style="font-size: 1.2rem; margin-bottom: 0.5rem;">Description</h3>
<p id="theme-details-desc" style="white-space: pre-wrap; color: var(--foreground); line-height: 1.6;"></p>
</div>
</div>
</div>
<div id="theme-store-browse" class="search-tab-content active" style="flex: 1; overflow-y: auto; min-height: 0;">
<div class="track-list-search-container" style="margin: 0 0 1rem 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" id="theme-store-search" placeholder="Search themes..." class="track-list-search-input" autocomplete="off">
</div>
<div id="community-themes-grid" class="card-grid">
</div>
<div id="theme-store-loading" style="text-align: center; padding: 2rem; display: none;">
<div class="animate-spin" style="display: inline-block;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
</div>
</div>
</div>
<div id="theme-store-upload" class="search-tab-content" style="flex: 1; overflow-y: auto; min-height: 0;">
<div id="theme-upload-auth-message" style="display: none; text-align: center; padding: 2rem; flex-direction: column; align-items: center; gap: 1rem;">
<p>You need to be logged in to upload themes.</p>
<button class="btn-primary" id="theme-store-login-btn">Go to Login</button>
</div>
<form id="theme-upload-form">
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Theme Name</label>
<input type="text" id="theme-upload-name" class="template-input" placeholder="My Awesome Theme" required>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Description</label>
<textarea id="theme-upload-desc" class="template-input" placeholder="Describe your theme..." style="min-height: 80px; resize: vertical;"></textarea>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">Author Website (Optional)</label>
<input type="url" id="theme-upload-website" class="template-input" placeholder="https://example.com">
<p style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.25rem;">
It is recommended to create a Monochrome profile instead.
</p>
</div>
<div style="margin-bottom: 1rem;">
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem;">CSS</label>
<p style="font-size: 0.8rem; color: var(--muted-foreground); margin-bottom: 0.5rem;">
Define your CSS variables or custom styles here. <a href="https://github.com/monochrome-music/monochrome/blob/main/THEME_GUIDE.md" target="_blank" style="text-decoration: underline; color: var(--primary);">Read the Theme Guide</a>.
</p>
<div class="theme-editor-toolbar">
<div class="color-picker-group">
<input type="color" id="te-bg-color" title="Background">
<input type="color" id="te-fg-color" title="Foreground">
<input type="color" id="te-primary-color" title="Primary">
<input type="color" id="te-sec-color" title="Secondary">
<input type="color" id="te-accent-color" title="Accent/Highlight">
<input type="color" id="te-card-color" title="Card Background">
<input type="color" id="te-border-color" title="Border Color">
<input type="color" id="te-muted-color" title="Muted Text">
</div>
<div class="style-picker-group">
<select id="te-font-family" title="Font Family">
<option value="">Font...</option>
<option value="'Inter', sans-serif">Inter</option>
<option value="system-ui, -apple-system, sans-serif">System</option>
<option value="'Courier New', monospace">Mono</option>
<option value="'Times New Roman', serif">Serif</option>
</select>
<input type="text" id="te-font-custom" placeholder="Custom Font..." style="height: 24px; padding: 0 4px; font-size: 0.75rem; width: 100px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--input); color: var(--foreground);">
<select id="te-radius" title="Border Radius">
<option value="">Radius...</option>
<option value="0px">Square</option>
<option value="4px">Small</option>
<option value="8px">Medium</option>
<option value="16px">Large</option>
</select>
</div>
<div class="editor-actions">
<button type="button" id="te-insert-template" class="btn-secondary" style="padding: 2px 8px; font-size: 0.8rem;">Template</button>
<button type="button" id="te-toggle-preview" class="btn-secondary" style="padding: 2px 8px; font-size: 0.8rem;">Preview</button>
</div>
</div>
<textarea id="theme-upload-css" class="template-input" style="min-height: 200px; font-family: monospace; white-space: pre;" placeholder=":root {&#10; --background: #000000;&#10; --foreground: #ffffff;&#10; /* ... */&#10;}" required></textarea>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary">Upload Theme</button>
</div>
</form>
</div>
</div>
</div>
<div id="theme-preview-window" style="display: none;"></div>
<div id="tracker-modal" class="modal tracker-modal">
<div class="modal-overlay"></div>
<div class="modal-content wide" style="max-height: 85vh; display: flex; flex-direction: column">
@ -2974,6 +3097,17 @@
<div class="theme-option" data-theme="latte">Latte</div>
<div class="theme-option" data-theme="custom">Custom</div>
</div>
<div id="applied-community-theme-container" style="display: none; margin-top: 1rem;">
<button id="applied-community-theme-btn" class="btn-secondary" style="width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 1rem;">
<span>Applied Community Theme</span>
<span id="applied-theme-name" style="color: var(--muted-foreground); font-size: 0.9rem;"></span>
</button>
<div id="community-theme-details-panel" style="display: none; padding: 1rem; background: var(--secondary); border-radius: var(--radius); margin-top: 0.5rem; border: 1px solid var(--border);">
<div style="margin-bottom: 0.5rem; font-weight: 600;" id="ct-details-title"></div>
<div style="margin-bottom: 1rem; font-size: 0.9rem; color: var(--muted-foreground);" id="ct-details-author"></div>
<button id="ct-unapply-btn" class="btn-secondary danger" style="width: 100%;">Unapply Theme</button>
</div>
</div>
<div class="custom-theme-editor" id="custom-theme-editor">
<h4>Custom Theme</h4>
<div class="theme-color-grid" id="theme-color-grid"></div>
@ -2982,6 +3116,13 @@
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
</div>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Community Themes</span>
<span class="description">Browse and apply themes created by the community</span>
</div>
<button id="open-theme-store-btn" class="btn-secondary">Open Theme Store</button>
</div>
<div class="setting-item font-settings-container">
<div class="info">
<span class="label">Font</span>

View file

@ -24,6 +24,7 @@ import { authManager } from './accounts/auth.js';
import { registerSW } from 'virtual:pwa-register';
import './smooth-scrolling.js';
import { openEditProfile } from './profile.js';
import { ThemeStore } from './themeStore.js';
import { initTracker } from './tracker.js';
import {
@ -296,6 +297,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize analytics
initAnalytics();
new ThemeStore();
const api = new MusicAPI(apiSettings);
const audioPlayer = document.getElementById('audio-player');

View file

@ -679,6 +679,61 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
});
const communityThemeContainer = document.getElementById('applied-community-theme-container');
const communityThemeBtn = document.getElementById('applied-community-theme-btn');
const communityThemeDetails = document.getElementById('community-theme-details-panel');
const communityThemeUnapplyBtn = document.getElementById('ct-unapply-btn');
const appliedThemeName = document.getElementById('applied-theme-name');
const ctDetailsTitle = document.getElementById('ct-details-title');
const ctDetailsAuthor = document.getElementById('ct-details-author');
function updateCommunityThemeUI() {
const metadataStr = localStorage.getItem('community-theme');
if (metadataStr) {
try {
const metadata = JSON.parse(metadataStr);
if (communityThemeContainer) communityThemeContainer.style.display = 'block';
if (appliedThemeName) appliedThemeName.textContent = metadata.name;
if (ctDetailsTitle) ctDetailsTitle.textContent = metadata.name;
if (ctDetailsAuthor) ctDetailsAuthor.textContent = `by ${metadata.author}`;
} catch {
if (communityThemeContainer) communityThemeContainer.style.display = 'none';
}
} else {
if (communityThemeContainer) communityThemeContainer.style.display = 'none';
if (communityThemeDetails) communityThemeDetails.style.display = 'none';
}
}
updateCommunityThemeUI();
window.addEventListener('theme-changed', updateCommunityThemeUI);
if (communityThemeBtn) {
communityThemeBtn.addEventListener('click', () => {
const isVisible = communityThemeDetails.style.display === 'block';
communityThemeDetails.style.display = isVisible ? 'none' : 'block';
});
}
if (communityThemeUnapplyBtn) {
communityThemeUnapplyBtn.addEventListener('click', () => {
if (confirm('Unapply this community theme?')) {
localStorage.removeItem('custom_theme_css');
localStorage.removeItem('community-theme');
const styleEl = document.getElementById('custom-theme-style');
if (styleEl) styleEl.remove();
themeManager.setTheme('system');
const themePicker = document.getElementById('theme-picker');
if (themePicker) {
themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active'));
themePicker.querySelector('[data-theme="system"]')?.classList.add('active');
}
document.getElementById('custom-theme-editor').classList.remove('show');
}
});
}
function renderCustomThemeEditor() {
const grid = document.getElementById('theme-color-grid');
const customTheme = themeManager.getCustomTheme() || {

713
js/themeStore.js Normal file
View file

@ -0,0 +1,713 @@
import { syncManager } from './accounts/pocketbase.js';
import { authManager } from './accounts/auth.js';
import { navigate } from './router.js';
export class ThemeStore {
constructor() {
this.pb = syncManager.pb;
this.modal = document.getElementById('theme-store-modal');
this.grid = document.getElementById('community-themes-grid');
this.uploadForm = document.getElementById('theme-upload-form');
this.searchInput = document.getElementById('theme-store-search');
this.loadingIndicator = document.getElementById('theme-store-loading');
this._isCheckingAuth = false;
this.previewShadow = null;
this.init();
}
init() {
document.getElementById('open-theme-store-btn')?.addEventListener('click', () => {
this.modal.classList.add('active');
this.loadThemes();
});
this.modal?.querySelector('.close-modal-btn')?.addEventListener('click', () => {
this.modal.classList.remove('active');
});
const tabs = this.modal?.querySelectorAll('.search-tab');
tabs?.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
this.modal.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const contentId = tab.dataset.tab === 'browse' ? 'theme-store-browse' : 'theme-store-upload';
document.getElementById(contentId)?.classList.add('active');
if (tab.dataset.tab === 'upload') {
this.checkAuth();
}
});
});
let debounceTimer;
this.searchInput?.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => this.loadThemes(e.target.value), 300);
});
this.uploadForm?.addEventListener('submit', (e) => this.handleUpload(e));
if (authManager) {
authManager.onAuthStateChanged(() => {
if (this.modal.classList.contains('active')) {
this.checkAuth();
}
});
}
document.getElementById('theme-store-login-btn')?.addEventListener('click', () => {
this.modal.classList.remove('active');
document.getElementById('email-auth-modal')?.classList.add('active');
});
this.setupEditorTools();
document.getElementById('theme-details-back-btn')?.addEventListener('click', () => {
this.closeThemeDetails();
});
this.applySavedTheme();
}
applySavedTheme() {
const theme = localStorage.getItem('monochrome-theme');
const css = localStorage.getItem('custom_theme_css');
if (theme === 'custom' && css) {
const metadataStr = localStorage.getItem('community-theme');
let metadata = null;
if (metadataStr) {
try { metadata = JSON.parse(metadataStr); } catch (e) { console.warn(e); }
}
if (metadata) {
this.applyTheme({
css: css,
id: metadata.id,
name: metadata.name,
authorName: metadata.author
});
} else {
this.applyTheme(css);
}
}
}
async loadThemes(query = '') {
if (!this.grid) return;
this.grid.innerHTML = '';
this.loadingIndicator.style.display = 'block';
let currentUserId = null;
if (authManager.user) {
try {
const record = await syncManager._getUserRecord(authManager.user.uid);
currentUserId = record?.id;
} catch (e) {
console.warn('Failed to resolve user ID for theme ownership check', e);
}
}
try {
const result = await this.pb.collection('themes').getList(1, 50, {
sort: '-created',
filter: query ? `name ~ "${query}" || description ~ "${query}"` : '',
expand: 'author'
});
this.loadingIndicator.style.display = 'none';
if (result.items.length === 0) {
this.grid.innerHTML = '<div class="empty-state">No themes found.</div>';
return;
}
result.items.forEach(theme => {
this.grid.appendChild(this.createThemeCard(theme, currentUserId));
});
} catch (err) {
console.error('Failed to load themes:', err);
this.loadingIndicator.style.display = 'none';
this.grid.innerHTML = '<div class="empty-state">Failed to load themes.</div>';
}
}
createThemeCard(theme, currentUserId) {
const div = document.createElement('div');
div.className = 'card theme-card';
const authorName = theme.expand?.author?.username
|| theme.expand?.author?.display_name
|| theme.authorName
|| 'Unknown';
const shortDesc = theme.description ?
(theme.description.length > 80 ? theme.description.substring(0, 80) + '...' : theme.description)
: '';
let authorHtml = this.escapeHtml(authorName);
let isInternalProfile = false;
if (theme.expand?.author?.username) {
isInternalProfile = true;
authorHtml = `<span class="author-link" style="cursor: pointer; text-decoration: underline;">${this.escapeHtml(authorName)}</span>`;
} else if (theme.authorUrl) {
authorHtml = `<a href="${this.escapeHtml(theme.authorUrl)}" target="_blank" style="color: inherit; text-decoration: underline;" onclick="event.stopPropagation();">${this.escapeHtml(authorName)}</a>`;
}
let deleteBtnHtml = '';
if (currentUserId && theme.author === currentUserId) {
deleteBtnHtml = `
<button class="btn-icon delete-theme-btn" title="Delete Theme" style="position: absolute; top: 0.5rem; right: 0.5rem; background: rgba(0,0,0,0.6); color: white; border-radius: 50%; padding: 0.25rem; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: none; cursor: pointer; z-index: 10;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
</button>
`;
}
const previewStyle = this.extractPreviewStyles(theme.css);
const previewHtml = `
<div class="theme-card-preview" style="${previewStyle}; height: 140px; position: relative;">
<div class="theme-card-preview-header" style="background-color: var(--card); border-bottom: 1px solid var(--border);"></div>
<div class="theme-card-preview-body" style="background-color: var(--background);">
<div class="theme-card-preview-line" style="background-color: var(--foreground); width: 80%;"></div>
<div class="theme-card-preview-line" style="background-color: var(--muted-foreground); width: 60%;"></div>
<div class="theme-card-preview-line" style="background-color: var(--primary); width: 40%; margin-top: auto;"></div>
</div>
</div>`;
div.innerHTML = `
<div style="position: relative;">
${deleteBtnHtml}
${previewHtml}
</div>
<div class="card-info" style="margin-top: 0.75rem;">
<div class="card-title">${this.escapeHtml(theme.name)}</div>
<div class="card-subtitle">by ${authorHtml}</div>
<p style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.25rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">
${this.escapeHtml(shortDesc)}
</p>
</div>
`;
div.addEventListener('click', (e) => {
if (e.target.closest('.delete-theme-btn')) {
e.stopPropagation();
this.deleteTheme(theme.id);
return;
}
this.openThemeDetails(theme);
});
if (isInternalProfile) {
const link = div.querySelector('.author-link');
link?.addEventListener('click', (e) => {
e.stopPropagation();
this.modal.classList.remove('active');
navigate(`/user/@${theme.expand.author.username}`);
});
}
return div;
}
async deleteTheme(themeId) {
if (!confirm('Are you sure you want to delete this theme?')) return;
try {
const fbUser = authManager.user;
if (!fbUser) throw new Error('Not authenticated');
await this.pb.collection('themes').delete(themeId, { f_id: fbUser.uid });
alert('Theme deleted successfully.');
this.loadThemes();
} catch (err) {
console.error('Failed to delete theme:', err);
alert('Failed to delete theme. You might not have permission.');
}
}
openThemeDetails(theme) {
const detailsView = document.getElementById('theme-store-details');
const browseView = document.getElementById('theme-store-browse');
const tabs = this.modal.querySelector('.search-tabs');
document.getElementById('theme-details-name').textContent = theme.name;
const authorName = theme.expand?.author?.username || theme.expand?.author?.display_name || theme.authorName || 'Unknown';
const authorEl = document.getElementById('theme-details-author');
if (theme.expand?.author?.username) {
authorEl.innerHTML = `by <span style="cursor: pointer; text-decoration: underline; color: var(--primary);">${this.escapeHtml(authorName)}</span>`;
authorEl.querySelector('span').onclick = () => {
this.modal.classList.remove('active');
navigate(`/user/@${theme.expand.author.username}`);
};
} else {
authorEl.textContent = `by ${authorName}`;
}
document.getElementById('theme-details-created').textContent = new Date(theme.created).toLocaleDateString();
document.getElementById('theme-details-updated').textContent = new Date(theme.updated).toLocaleDateString();
document.getElementById('theme-details-installs').textContent = theme.installs || 0;
document.getElementById('theme-details-desc').textContent = theme.description || 'No description provided.';
const applyBtn = document.getElementById('theme-details-apply-btn');
applyBtn.onclick = async () => {
this.applyTheme(theme);
this.modal.classList.remove('active');
try {
const latest = await this.pb.collection('themes').getOne(theme.id);
await this.pb.collection('themes').update(theme.id, {
installs: (latest.installs || 0) + 1
});
} catch (e) {
console.warn('Failed to update theme installs:', e);
}
};
const previewContainer = document.getElementById('theme-details-preview-container');
previewContainer.innerHTML = '';
this.detailsPreviewShadow = previewContainer.attachShadow({ mode: 'open' });
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/styles.css';
this.detailsPreviewShadow.appendChild(link);
const styleTag = document.createElement('style');
styleTag.textContent = theme.css.replace(/:root/g, ':host');
this.detailsPreviewShadow.appendChild(styleTag);
const wrapper = document.createElement('div');
wrapper.className = 'preview-content';
wrapper.style.padding = '1rem';
wrapper.style.height = '100%';
wrapper.style.background = 'var(--background)';
wrapper.style.color = 'var(--foreground)';
wrapper.style.overflow = 'hidden';
wrapper.innerHTML = `
<div class="card" style="margin-bottom: 1rem;">
<div style="height: 60px; background: var(--muted); border-radius: var(--radius); margin-bottom: 0.5rem;"></div>
<div class="card-title">Preview</div>
<div class="card-subtitle">Subtitle</div>
</div>
<button class="btn-primary" style="margin-bottom: 0.5rem; width: 100%;">Button</button>
`;
this.detailsPreviewShadow.appendChild(wrapper);
browseView.style.display = 'none';
tabs.style.display = 'none';
detailsView.style.display = 'flex';
}
closeThemeDetails() {
const detailsView = document.getElementById('theme-store-details');
const browseView = document.getElementById('theme-store-browse');
const tabs = this.modal.querySelector('.search-tabs');
detailsView.style.display = 'none';
browseView.style.display = 'block';
tabs.style.display = 'flex';
document.getElementById('theme-details-preview-container').innerHTML = '';
}
extractPreviewStyles(css) {
const vars = ['--background', '--foreground', '--primary', '--card', '--border', '--muted-foreground'];
let style = '';
vars.forEach(v => {
const regex = new RegExp(`${v}\\s*:\\s*([^;]+)`);
const match = css.match(regex);
if (match) {
style += `${v}: ${match[1]}; `;
}
});
return style;
}
applyTheme(theme) {
let css = theme.css;
if (!css && typeof theme === 'string') {
css = theme;
theme = { name: 'Custom Theme', authorName: 'Unknown' };
}
localStorage.setItem('custom_theme_css', css);
localStorage.setItem('monochrome-theme', 'custom');
const metadata = {
id: theme.id,
name: theme.name,
author: theme.authorName || theme.expand?.author?.username || theme.expand?.author?.display_name || 'Unknown',
};
localStorage.setItem('community-theme', JSON.stringify(metadata));
let styleEl = document.getElementById('custom-theme-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'custom-theme-style';
document.head.appendChild(styleEl);
}
const fontMatch = css.match(/--font-family:\s*([^;}]+)/);
const urlMatch = css.match(/--font-url:\s*([^;}]+)/);
if (fontMatch && fontMatch[1]) {
const fontFamilyValue = fontMatch[1].trim();
const mainFont = fontFamilyValue.split(',')[0].trim().replace(/['"]/g, '');
const genericFamilies = [
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'inter', 'ibm plex mono', 'roboto', 'open sans',
'lato', 'montserrat', 'poppins', 'apple music', 'sf pro display',
'courier new', 'times new roman', 'arial', 'helvetica', 'verdana',
'tahoma', 'trebuchet ms', 'impact', 'gill sans'
];
const isPresetOrGeneric = genericFamilies.some(generic => mainFont.toLowerCase() === generic);
if (!isPresetOrGeneric) {
const FONT_LINK_ID = 'monochrome-dynamic-font';
let link = document.getElementById(FONT_LINK_ID);
if (urlMatch && urlMatch[1]) {
const customUrl = urlMatch[1].trim().replace(/['"]/g, '');
console.log(`Applying custom font URL: ${customUrl}`);
if (customUrl.match(/\.(css)$/i) || customUrl.includes('fonts.googleapis.com')) {
if (!link) {
link = document.createElement('link');
link.id = FONT_LINK_ID;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
link.href = customUrl;
} else {
if (link) link.remove();
const fontFace = `
@font-face {
font-family: '${mainFont}';
src: url('${customUrl}');
font-weight: 100 900;
font-display: swap;
}
`;
css = fontFace + css;
}
} else {
console.log(`Applying custom font from theme (Google Fonts): ${mainFont}`);
const encodedFamily = encodeURIComponent(mainFont);
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@100;200;300;400;500;600;700;800;900&display=swap`;
if (!link) {
link = document.createElement('link');
link.id = FONT_LINK_ID;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
link.href = url;
}
}
}
styleEl.textContent = css;
const root = document.documentElement;
['background', 'foreground', 'primary', 'secondary', 'muted', 'border', 'highlight', 'font-family'].forEach(key => {
root.style.removeProperty(`--${key}`);
});
root.setAttribute('data-theme', 'custom');
document.querySelectorAll('.theme-option').forEach(el => el.classList.remove('active'));
document.querySelector('[data-theme="custom"]')?.classList.add('active');
document.documentElement.style.display = 'none';
document.documentElement.offsetHeight;
document.documentElement.style.display = '';
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme: 'custom' } }));
}
async checkAuth() {
if (this._isCheckingAuth) return;
this._isCheckingAuth = true;
const isLoggedIn = !!authManager?.user;
const authMessage = document.getElementById('theme-upload-auth-message');
const form = document.getElementById('theme-upload-form');
const websiteInput = document.getElementById('theme-upload-website');
const websiteContainer = websiteInput?.parentElement;
if (isLoggedIn) {
authMessage.style.display = 'none';
form.style.display = 'block';
try {
const userData = await syncManager.getUserData();
if (userData?.profile?.username && websiteContainer) {
websiteContainer.style.display = 'none';
} else if (websiteContainer) {
websiteContainer.style.display = 'block';
}
} catch (e) {
console.warn('Failed to check profile for website input visibility', e);
}
} else {
authMessage.style.display = 'flex';
form.style.display = 'none';
}
this._isCheckingAuth = false;
}
async handleUpload(e) {
e.preventDefault();
const name = document.getElementById('theme-upload-name').value;
const desc = document.getElementById('theme-upload-desc').value;
const css = document.getElementById('theme-upload-css').value;
const website = document.getElementById('theme-upload-website').value;
const fbUser = authManager?.user;
if (!fbUser) {
alert('You must be logged in to upload themes.');
return;
}
let userId = null;
let userName = null;
try {
const dbUser = await syncManager._getUserRecord(fbUser.uid);
if (!dbUser) {
throw new Error('Could not find or create your user record. Please try again.');
}
userId = dbUser.id;
userName = dbUser.username || dbUser.display_name || fbUser.email;
if (userId.length !== 15) {
throw new Error(
`Your user ID is corrupted (${userId.length} chars, expected 15). ` +
`Please go to Settings > System > Clear Cloud Data, then log out and back in.`
);
}
console.log('Uploading theme:', { name, author: userId, authorName: userName });
const formData = new FormData();
formData.append('name', name);
formData.append('description', desc);
formData.append('css', css);
formData.append('author', userId);
formData.append('authorName', userName);
if (website) formData.append('authorUrl', website);
await this.pb.collection('themes').create(formData, { f_id: fbUser.uid });
alert('Theme uploaded successfully!');
e.target.reset();
const previewWindow = document.getElementById('theme-preview-window');
const togglePreviewBtn = document.getElementById('te-toggle-preview');
if (previewWindow) previewWindow.style.display = 'none';
if (togglePreviewBtn) {
togglePreviewBtn.textContent = 'Preview';
togglePreviewBtn.classList.remove('active');
}
this.modal.querySelector('[data-tab="browse"]').click();
this.loadThemes();
} catch (err) {
console.error('Upload failed:', err);
console.error('Response data:', err.data);
const responseData = err.data?.data || {};
if (Object.keys(responseData).length > 0) {
let msg = 'Failed to upload theme:\n';
for (const [key, value] of Object.entries(responseData)) {
msg += `${key}: ${value.message}\n`;
}
alert(msg);
} else {
const message = err.message || err.data?.message || 'Unknown error';
const debugInfo = `\n\nDebug: User ID: ${userId} (${userId?.length} chars) | Status: ${err.status}`;
alert(`Failed to upload theme: ${message}${debugInfo}`);
}
}
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
setupEditorTools() {
const cssInput = document.getElementById('theme-upload-css');
const insertTemplateBtn = document.getElementById('te-insert-template');
const togglePreviewBtn = document.getElementById('te-toggle-preview');
const previewWindow = document.getElementById('theme-preview-window');
const colorMap = {
'te-bg-color': '--background',
'te-fg-color': '--foreground',
'te-primary-color': '--primary',
'te-sec-color': '--secondary',
'te-accent-color': '--highlight',
'te-card-color': '--card',
'te-border-color': '--border',
'te-muted-color': '--muted-foreground'
};
Object.entries(colorMap).forEach(([id, variable]) => {
document.getElementById(id)?.addEventListener('input', (e) => {
this.updateCssVariable(cssInput, variable, e.target.value);
this.updatePreview();
});
});
const styleMap = {
'te-font-family': '--font-family',
'te-radius': '--radius'
};
Object.entries(styleMap).forEach(([id, variable]) => {
document.getElementById(id)?.addEventListener('change', (e) => {
if (e.target.value) {
this.updateCssVariable(cssInput, variable, e.target.value);
this.updatePreview();
e.target.value = "";
}
});
});
document.getElementById('te-font-custom')?.addEventListener('input', (e) => {
this.updateCssVariable(cssInput, '--font-family', e.target.value);
this.updatePreview();
});
insertTemplateBtn?.addEventListener('click', () => {
if (cssInput.value.trim() && !confirm('Overwrite current CSS with template?')) return;
cssInput.value = `:root {
/* Base Colors */
--background: #0a0a0a;
--foreground: #ededed;
/* UI Elements */
--card: #1a1a1a;
--card-foreground: #ededed;
--border: #2a2a2a;
/* Accents */
--primary: #3b82f6;
--primary-foreground: #ffffff;
--secondary: #2a2a2a;
--secondary-foreground: #ededed;
/* Text */
--muted: #2a2a2a;
--muted-foreground: #a0a0a0;
/* Special */
--highlight: #3b82f6;
--ring: #3b82f6;
--radius: 8px;
--font-family: 'Inter', sans-serif;
--font-size-scale: 100%;
}`;
this.updatePreview();
});
togglePreviewBtn?.addEventListener('click', () => {
const isVisible = previewWindow.style.display !== 'none';
if (isVisible) {
previewWindow.style.display = 'none';
togglePreviewBtn.textContent = 'Preview';
togglePreviewBtn.classList.remove('active');
} else {
previewWindow.style.display = 'flex';
togglePreviewBtn.textContent = 'Close Preview';
togglePreviewBtn.classList.add('active');
this.initPreviewWindow();
this.updatePreview();
}
});
cssInput?.addEventListener('input', () => this.updatePreview());
}
updateCssVariable(textarea, variable, value) {
let css = textarea.value;
const regex = new RegExp(`${variable}:\\s*[^;\\}]+(?:;|(?=\\}))`, 'g');
const newLine = `${variable}: ${value};`;
if (regex.test(css)) {
css = css.replace(regex, newLine);
} else {
if (css.includes(':root {')) {
css = css.replace(':root {', `:root {\n ${newLine}`);
} else {
css += `\n:root {\n ${newLine}\n}`;
}
}
textarea.value = css;
}
initPreviewWindow() {
const container = document.getElementById('theme-preview-window');
if (!this.previewShadow) {
this.previewShadow = container.attachShadow({ mode: 'open' });
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/styles.css';
this.previewShadow.appendChild(link);
this.previewStyleTag = document.createElement('style');
this.previewShadow.appendChild(this.previewStyleTag);
const wrapper = document.createElement('div');
wrapper.className = 'preview-content';
wrapper.style.padding = '1rem';
wrapper.style.height = '100%';
wrapper.style.background = 'var(--background)';
wrapper.style.color = 'var(--foreground)';
wrapper.style.overflow = 'auto';
wrapper.innerHTML = `
<h3 style="margin-top: 0;">Preview</h3>
<div class="card" style="margin-bottom: 1rem;">
<div style="height: 100px; background: var(--muted); border-radius: var(--radius); margin-bottom: 0.5rem;"></div>
<div class="card-title">Card Title</div>
<div class="card-subtitle">Subtitle</div>
</div>
<button class="btn-primary" style="margin-bottom: 0.5rem;">Primary Button</button>
<button class="btn-secondary">Secondary Button</button>
<p style="color: var(--muted-foreground);">Muted text example.</p>
`;
this.previewShadow.appendChild(wrapper);
}
}
updatePreview() {
if (!this.previewShadow || !this.previewStyleTag) return;
const css = document.getElementById('theme-upload-css').value;
const scopedCss = css.replace(/:root/g, ':host');
this.previewStyleTag.textContent = scopedCss;
}
}

View file

@ -7558,3 +7558,100 @@ textarea:focus {
text-align: left;
justify-content: flex-start;
}
.theme-card-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
.theme-card-preview-header {
height: 30%;
border-bottom: 1px solid var(--border);
}
.theme-card-preview-body {
flex: 1;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.theme-card-preview-line {
height: 4px;
border-radius: 2px;
width: 100%;
opacity: 0.5;
}
.theme-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--secondary);
padding: 0.5rem;
border: 1px solid var(--border);
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
flex-wrap: wrap;
gap: 0.5rem;
}
.theme-editor-toolbar .color-picker-group {
display: flex;
gap: 0.25rem;
}
.theme-editor-toolbar .style-picker-group {
display: flex;
gap: 0.25rem;
}
.theme-editor-toolbar select {
height: 24px;
padding: 0 4px;
font-size: 0.75rem;
background-color: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--foreground);
cursor: pointer;
}
.theme-editor-toolbar input[type="color"] {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
}
#theme-upload-css {
border-top-left-radius: 0;
border-top-right-radius: 0;
max-width: 100%;
}
#theme-preview-window {
position: fixed;
bottom: 20px;
right: 20px;
width: 300px;
height: 400px;
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-xl);
z-index: 10000;
overflow: hidden;
resize: both;
display: flex;
flex-direction: column;
}