// Services Page JavaScript (function() { 'use strict'; // Discord webhook for form submissions (public - submissions go to private channel for review) const WEBHOOK_URL = 'https://freelance.vdo.workers.dev'; // Fetch approved services from GitHub Gist (updated by Discord bot on approval) const GIST_ID = '3642a19e9ed4b16571906cdb2e216a45'; const GIST_URL = GIST_ID ? `https://gist.githubusercontent.com/steveseguin/${GIST_ID}/raw/services.json` : 'data/services.json'; // Fallback to local file // DOM Elements let servicesGrid; let platformFilters; let submitToggleBtn; let submissionForm; let formMessage; let adminPanel; // State let services = []; let currentFilter = 'ssn'; // Default to SSN filter // Initialize document.addEventListener('DOMContentLoaded', function() { servicesGrid = document.getElementById('services-grid'); platformFilters = document.querySelectorAll('.platform-filter'); submitToggleBtn = document.getElementById('submit-toggle-btn'); submissionForm = document.getElementById('submission-form-wrapper'); formMessage = document.getElementById('form-message'); adminPanel = document.getElementById('admin-panel'); loadServices(); initFilters(); initFormToggle(); initForm(); initAdmin(); initMultiInputs(); initDiscordAuth(); initDiscordHelp(); }); // Load services from Gist (or fallback to local JSON) async function loadServices() { try { // Add cache-busting for gist URL to get latest data const url = GIST_ID ? `${GIST_URL}?t=${Date.now()}` : GIST_URL; const response = await fetch(url); const data = await response.json(); services = data.services || []; renderServices(); } catch (error) { console.error('Failed to load services:', error); renderEmptyState(); } } // Render services grid function renderServices() { if (!servicesGrid) return; const filtered = currentFilter === 'all' ? services : services.filter(s => s.platforms && s.platforms.includes(currentFilter)); if (filtered.length === 0) { renderEmptyState(); return; } servicesGrid.innerHTML = filtered.map(service => createServiceCard(service)).join(''); // Add click handlers for portfolio images document.querySelectorAll('.portfolio-thumb').forEach(img => { img.addEventListener('click', (e) => { e.stopPropagation(); openPortfolioModal(img.dataset.fullSrc || img.src); }); }); // Add click handlers for reveal links (SEO protection) document.querySelectorAll('.reveal-link').forEach(span => { span.addEventListener('click', function() { if (this.classList.contains('revealed')) return; const encodedUrl = this.dataset.url; const url = atob(encodedUrl); // Decode Base64 // Extract domain name from URL let domain; try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = url; } this.classList.add('revealed'); this.innerHTML = `${escapeHtml(domain)}`; }); }); } // Create service card HTML function createServiceCard(service) { const initials = getInitials(service.name); const platformBadges = (service.platforms || []).map(p => `${p === 'ssn' ? 'SSN' : 'VDO.Ninja'}` ).join(''); const typeTags = (service.serviceTypes || []).map(t => `${t}` ).join(''); const portfolio = (service.portfolio || []).slice(0, 5).map(url => `Portfolio` ).join(''); const socials = createSocialLinks(service.socials || {}); const contactLink = service.socials?.discord || service.socials?.website || (service.paymentLinks && service.paymentLinks[0]) || '#'; // Use real avatar if available, otherwise show initials const avatarHtml = service.avatarUrl ? `${escapeHtml(service.name)}` : `
${initials}
`; return `
${avatarHtml}

${escapeHtml(service.name)}

Discord ${escapeHtml(service.discord || '')}
${platformBadges}
${typeTags}

${escapeHtml(service.description || '')}

${portfolio ? `
${portfolio}
` : ''}
`; } // Create social links HTML (click-to-reveal for SEO protection) function createSocialLinks(socials) { const links = []; if (socials.discord) { links.push(createRevealLink('Discord', socials.discord, '../icons/discord.svg')); } if (socials.instagram) { links.push(createRevealLink('Instagram', socials.instagram, '../icons/instagram.svg')); } if (socials.twitter) { links.push(createRevealLink('X/Twitter', socials.twitter, '../icons/x.svg')); } if (socials.website) { links.push(createRevealLink('Website', socials.website, '../icons/link.svg')); } return links.join(''); } // Create a click-to-reveal link (prevents SEO crawling of external links) function createRevealLink(label, url, icon) { const encodedUrl = btoa(url); // Base64 encode to hide from crawlers return ` ${label} `; } // Render empty state function renderEmptyState() { if (!servicesGrid) return; servicesGrid.innerHTML = `

No Services Listed Yet

Be the first to offer your freelance services to the community!

Scroll down to submit your listing.

`; } // Initialize platform filters function initFilters() { platformFilters.forEach(filter => { filter.addEventListener('click', () => { platformFilters.forEach(f => f.classList.remove('active')); filter.classList.add('active'); currentFilter = filter.dataset.filter; renderServices(); }); }); } // Initialize form toggle function initFormToggle() { if (submitToggleBtn && submissionForm) { submitToggleBtn.addEventListener('click', () => { submissionForm.classList.toggle('active'); submitToggleBtn.textContent = submissionForm.classList.contains('active') ? 'Hide Form' : 'Submit Your Listing'; }); } } // Initialize form function initForm() { const form = document.getElementById('service-form'); if (!form) return; form.addEventListener('submit', async (e) => { e.preventDefault(); await submitForm(form); }); // Terms checkbox validation const termsCheckbox = document.getElementById('agree-terms'); const submitBtn = document.getElementById('submit-btn'); if (termsCheckbox && submitBtn) { termsCheckbox.addEventListener('change', () => { submitBtn.disabled = !termsCheckbox.checked; }); } } // Sanitize text input - remove potentially dangerous characters function sanitizeText(text) { if (!text) return ''; // Remove null bytes and control characters except newlines/tabs return text .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') .replace(/[<>]/g, '') // Remove angle brackets to prevent injection .trim(); } // Check if text contains URLs/links function containsLinks(text) { const urlPatterns = [ /https?:\/\//i, /www\./i, /\.[a-z]{2,}\/\S/i, // domain.tld/path /discord\.(gg|com\/invite)/i, /t\.me\//i, /bit\.ly/i ]; return urlPatterns.some(pattern => pattern.test(text)); } // Submit form to Discord webhook async function submitForm(form) { const formData = new FormData(form); // Gather and sanitize data const name = sanitizeText(formData.get('name')); const discord = sanitizeText(formData.get('discord')); const discordId = formData.get('discord-id') || ''; const discordAvatar = formData.get('discord-avatar') || ''; const description = sanitizeText(formData.get('description')); // Validate name (alphanumeric, spaces, basic punctuation) if (!/^[\w\s\-'.&]+$/i.test(name)) { showFormMessage('error', 'Name contains invalid characters. Use letters, numbers, spaces, and basic punctuation only.'); return; } // Validate discord username (alphanumeric, underscores, periods) if (!/^[\w.]+$/i.test(discord)) { showFormMessage('error', 'Discord username contains invalid characters.'); return; } // Check for links in description if (containsLinks(description)) { showFormMessage('error', 'Description cannot contain links. Please use the Social & Contact Links section for URLs.'); return; } // Platforms (checkboxes) const platforms = []; if (formData.get('platform-ssn')) platforms.push('Social Stream Ninja'); if (formData.get('platform-vdo')) platforms.push('VDO.Ninja'); // Service types (checkboxes) const serviceTypes = []; document.querySelectorAll('input[name^="service-"]:checked').forEach(cb => { serviceTypes.push(cb.value); }); // Social links const socials = []; document.querySelectorAll('.social-input').forEach(input => { if (input.value.trim()) socials.push(input.value.trim()); }); // Portfolio URLs - only allow images from our file upload service const portfolio = []; const allowedImageHosts = ['fileuploads.socialstream.ninja', 'fileuploads.vdo.ninja']; let hasInvalidPortfolio = false; document.querySelectorAll('.portfolio-input').forEach(input => { const url = input.value.trim(); if (url) { try { const hostname = new URL(url).hostname; if (allowedImageHosts.includes(hostname)) { portfolio.push(url); } else { hasInvalidPortfolio = true; } } catch { hasInvalidPortfolio = true; } } }); if (hasInvalidPortfolio) { showFormMessage('error', 'Portfolio images must be uploaded using the upload button. External image URLs are not allowed.'); return; } // Payment links const payments = []; document.querySelectorAll('.payment-input').forEach(input => { if (input.value.trim()) payments.push(input.value.trim()); }); // Validate if (!name || !discord || !description) { showFormMessage('error', 'Please fill in all required fields.'); return; } if (platforms.length === 0) { showFormMessage('error', 'Please select at least one platform.'); return; } if (serviceTypes.length === 0) { showFormMessage('error', 'Please select at least one service type.'); return; } if (socials.length === 0) { showFormMessage('error', 'Please provide at least one social/contact link.'); return; } // Build webhook payload const embedFields = [ { name: 'Name', value: name, inline: true }, { name: 'Discord', value: discord, inline: true }, { name: 'Platforms', value: platforms.join(', ') || 'None', inline: false }, { name: 'Service Types', value: serviceTypes.join(', ') || 'None', inline: false }, { name: 'Description', value: description.substring(0, 1000), inline: false }, { name: 'Social Links', value: socials.join('\n') || 'None', inline: false }, { name: 'Portfolio URLs', value: portfolio.join('\n') || 'None', inline: false }, { name: 'Payment Links', value: payments.join('\n') || 'None', inline: false } ]; // Add Discord ID and avatar if user signed in with Discord if (discordId) { embedFields.push({ name: 'Discord ID', value: discordId, inline: true }); } if (discordAvatar) { embedFields.push({ name: 'Avatar URL', value: discordAvatar, inline: true }); } const payload = { username: 'Services Submission', embeds: [{ title: 'New Freelancer Submission', color: 7855479, // Purple thumbnail: discordAvatar ? { url: discordAvatar } : undefined, fields: embedFields, timestamp: new Date().toISOString() }] }; // Send to Discord try { const response = await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (response.ok) { showFormMessage('success', 'Your submission has been received! Reviews may take some time and you may not be notified of approval or rejection.'); form.reset(); document.getElementById('submit-btn').disabled = true; } else { throw new Error('Webhook request failed'); } } catch (error) { console.error('Submission error:', error); showFormMessage('error', 'Failed to submit. Please try again or contact support.'); } } // Show form message function showFormMessage(type, message) { if (!formMessage) return; formMessage.className = 'form-message ' + type; formMessage.textContent = message; formMessage.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Initialize admin panel (no longer needed - webhook is hardcoded) function initAdmin() { // Admin panel removed - webhook URL is now in code } // Initialize multi-input fields (social links, portfolio, payments) function initMultiInputs() { document.querySelectorAll('.add-input-btn').forEach(btn => { btn.addEventListener('click', () => { const group = btn.closest('.multi-input-group'); const inputClass = btn.dataset.inputClass; const placeholder = btn.dataset.placeholder || 'Enter URL'; const withUpload = btn.dataset.withUpload === 'true'; const row = document.createElement('div'); row.className = 'multi-input-row'; const isPortfolio = inputClass === 'portfolio-input'; row.innerHTML = ` ${withUpload ? '' : ''} `; group.insertBefore(row, btn); row.querySelector('.remove-btn').addEventListener('click', () => { row.remove(); }); // Add upload handler if this is a portfolio input if (withUpload) { const uploadBtn = row.querySelector('.upload-btn'); const input = row.querySelector('input'); uploadBtn.addEventListener('click', () => openFileUpload(input)); } }); }); // Add remove handlers to existing remove buttons document.querySelectorAll('.remove-btn').forEach(btn => { btn.addEventListener('click', () => { btn.closest('.multi-input-row').remove(); }); }); // Initialize existing upload buttons initUploadButtons(); } // Initialize upload buttons function initUploadButtons() { document.querySelectorAll('.upload-btn').forEach(btn => { btn.addEventListener('click', () => { const row = btn.closest('.multi-input-row'); const input = row.querySelector('input'); openFileUpload(input); }); }); } // Open file upload popup and handle result async function openFileUpload(targetInput) { function applyUploadedUrl(uploadData) { const uploadedUrl = uploadData && uploadData.url; if (!targetInput || !uploadedUrl) return false; targetInput.value = uploadedUrl; targetInput.dispatchEvent(new Event('input', { bubbles: true })); targetInput.dispatchEvent(new Event('change', { bubbles: true })); return true; } if (window.ninjafy && typeof window.ninjafy.startMediaUpload === 'function') { try { const result = await window.ninjafy.startMediaUpload({ popupName: 'uploadPortfolio' }); if (result && result.success) { applyUploadedUrl(result); } } catch (error) { console.warn('Hosted media upload failed:', error && error.message ? error.message : error); } return; } const popup = window.open( 'https://fileuploads.socialstream.ninja/popup/upload', 'uploadPortfolio', 'width=640,height=640' ); window.addEventListener('message', function handleMessage(event) { // Verify the origin for security if (event.origin !== 'https://fileuploads.socialstream.ninja') return; // Check if this is our media upload message if (event.data && event.data.type === 'media-uploaded') { applyUploadedUrl(event.data); // Remove this specific listener window.removeEventListener('message', handleMessage); } }); } // Discord OAuth sign-in function initDiscordAuth() { const signinBtn = document.getElementById('discord-signin-btn'); const disconnectBtn = document.getElementById('discord-disconnect-btn'); const authContainer = document.getElementById('discord-auth-container'); const connectedContainer = document.getElementById('discord-connected'); if (signinBtn) { signinBtn.addEventListener('click', () => { // Open Discord OAuth popup window.open( 'https://auth.socialstream.ninja/auth/discord/services', 'discordAuth', 'width=500,height=700' ); // Listen for the response window.addEventListener('message', function handleDiscordAuth(event) { // Accept from our auth services if (event.origin !== 'https://auth.socialstream.ninja' && event.origin !== 'https://auth.vdo.ninja') return; if (event.data.type === 'discord-auth-success') { // Store Discord data document.getElementById('discord-id').value = event.data.id; document.getElementById('discord-avatar').value = event.data.avatar; document.getElementById('discord').value = event.data.username; // Optionally use global_name as display name if empty const nameInput = document.getElementById('name'); if (nameInput && !nameInput.value && event.data.globalName) { nameInput.value = event.data.globalName; } // Show connected state document.getElementById('discord-avatar-preview').src = event.data.avatar; document.getElementById('discord-username-display').textContent = event.data.username; authContainer.style.display = 'none'; connectedContainer.style.display = 'flex'; // Make discord input readonly since we got it from OAuth document.getElementById('discord').readOnly = true; window.removeEventListener('message', handleDiscordAuth); } else if (event.data.type === 'discord-auth-error') { console.error('Discord auth error:', event.data.error); window.removeEventListener('message', handleDiscordAuth); } }); }); } if (disconnectBtn) { disconnectBtn.addEventListener('click', () => { // Clear Discord data document.getElementById('discord-id').value = ''; document.getElementById('discord-avatar').value = ''; document.getElementById('discord').value = ''; document.getElementById('discord').readOnly = false; // Show sign-in button again authContainer.style.display = 'block'; connectedContainer.style.display = 'none'; }); } } // Discord help modal function initDiscordHelp() { const helpLink = document.getElementById('discord-id-help'); if (helpLink) { helpLink.addEventListener('click', (e) => { e.preventDefault(); const modal = document.getElementById('discord-help-modal'); if (modal) modal.classList.add('active'); }); } } // Close Discord help modal (global function) window.closeDiscordHelpModal = function() { const modal = document.getElementById('discord-help-modal'); if (modal) modal.classList.remove('active'); }; // Click outside help modal to close document.addEventListener('click', (e) => { const modal = document.getElementById('discord-help-modal'); if (modal && e.target === modal) { modal.classList.remove('active'); } }); // Portfolio modal function openPortfolioModal(src) { const modal = document.getElementById('portfolio-modal'); const img = document.getElementById('portfolio-modal-img'); if (modal && img && src) { img.src = src; modal.classList.add('active'); // Force reflow to ensure modal displays modal.offsetHeight; } } // Close modal window.closePortfolioModal = function() { const modal = document.getElementById('portfolio-modal'); if (modal) { modal.classList.remove('active'); } }; // Click outside modal to close document.addEventListener('click', (e) => { const modal = document.getElementById('portfolio-modal'); if (modal && e.target === modal) { modal.classList.remove('active'); } }); // Utility functions function getInitials(name) { if (!name) return '?'; return name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase(); } function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } })();