Files
urllists/static/templates/create_list.html
2025-11-30 12:19:31 +00:00

362 lines
14 KiB
HTML

{% extends "base.html" %}
{% block title %}Create List - {{ app_name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto py-6">
<!-- Header -->
<div class="mb-6">
<a href="{{ url_for('dashboard') }}" class="text-sky-500 hover:text-sky-400 flex items-center gap-2 mb-3 text-sm">
<span></span> Back to Dashboard
</a>
<h1 class="text-3xl font-bold">Create New List</h1>
</div>
<!-- Form Card -->
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6">
<form id="createListForm" class="space-y-5">
<!-- Name and Slug Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- List Name Field -->
<div>
<label for="listName" class="block text-sm font-semibold text-slate-100 mb-2">
List Name <span class="text-red-400">*</span>
</label>
<input
type="text"
id="listName"
placeholder="e.g., Design Resources"
maxlength="100"
required
class="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500 transition text-sm"
>
<div class="mt-1 flex justify-between items-center">
<span id="nameError" class="text-red-400 text-xs hidden"></span>
<span id="nameCount" class="text-slate-500 text-xs ml-auto">0/100</span>
</div>
</div>
<!-- Unique Slug Field -->
<div>
<label for="listSlug" class="block text-sm font-semibold text-slate-100 mb-2">
Custom Slug <span class="text-slate-500 font-normal">(optional)</span>
</label>
<div class="flex gap-2">
<input
type="text"
id="listSlug"
placeholder="auto-generated"
maxlength="50"
class="flex-1 bg-slate-900 border border-slate-700 rounded px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500 transition text-sm"
>
<button
type="button"
id="generateSlugBtn"
class="bg-slate-700 hover:bg-slate-600 text-slate-100 font-semibold px-3 py-2 rounded transition duration-200 text-sm whitespace-nowrap"
title="Generate random slug"
>
🔄
</button>
</div>
<div class="mt-1 flex justify-between items-center">
<span id="slugError" class="text-red-400 text-xs hidden"></span>
<span id="slugCount" class="text-slate-500 text-xs ml-auto">0/50</span>
</div>
</div>
</div>
<!-- Share URL Preview -->
<div class="p-3 bg-slate-900 rounded border border-slate-700">
<p id="shareUrlPreview" class="text-sky-400 font-mono text-xs break-all">{{ request.base_url }}list-read/auto-generated</p>
</div>
<!-- URLs Field -->
<div>
<label for="listUrls" class="block text-sm font-semibold text-slate-100 mb-2">
URLs <span class="text-red-400">*</span>
</label>
<p class="text-xs text-slate-400 mb-2">Domain or subdomain only (e.g., example.com, api.example.com)</p>
<textarea
id="listUrls"
placeholder="example.com&#10;github.com&#10;api.example.com"
rows="7"
required
class="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500 transition font-mono text-xs resize-none"
spellcheck="false"
></textarea>
<div class="mt-1 flex justify-between items-center">
<span id="urlError" class="text-red-400 text-xs hidden"></span>
<span id="urlCount" class="text-slate-500 text-xs">0 URLs</span>
</div>
</div>
<!-- Public Visibility Toggle -->
<div class="flex items-center gap-3 p-3 bg-slate-900 rounded border border-slate-700">
<input
type="checkbox"
id="isPublic"
class="w-4 h-4 bg-slate-700 border border-slate-600 rounded cursor-pointer"
>
<label for="isPublic" class="cursor-pointer text-sm text-slate-200">
<span class="font-semibold">Make this list public</span>
<p class="text-xs text-slate-400 mt-1">Public lists appear on the home page and can be searched by others. Only you can edit or delete it.</p>
</label>
</div>
<!-- Form Actions -->
<div class="flex gap-3 pt-3 border-t border-slate-700">
<button
type="submit"
class="flex-1 bg-sky-600 hover:bg-sky-700 text-white font-semibold py-2 px-4 rounded transition duration-200 text-sm"
>
✓ Create List
</button>
<a
href="{{ url_for('dashboard') }}"
class="flex-1 bg-slate-700 hover:bg-slate-600 text-slate-100 font-semibold py-2 px-4 rounded transition duration-200 text-center text-sm"
>
Cancel
</a>
</div>
<!-- Status Message -->
<div id="statusMessage" class="hidden p-3 rounded border text-xs">
<span id="statusText"></span>
</div>
</form>
</div>
</div>
<script>
const form = document.getElementById('createListForm');
const nameInput = document.getElementById('listName');
const slugInput = document.getElementById('listSlug');
const urlInput = document.getElementById('listUrls');
const generateSlugBtn = document.getElementById('generateSlugBtn');
let editingListId = null;
// Generate random slug
function generateRandomSlug() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789-';
const length = Math.floor(Math.random() * (20 - 6 + 1)) + 6; // 6-20 chars
let slug = '';
for (let i = 0; i < length; i++) {
slug += chars.charAt(Math.floor(Math.random() * chars.length));
}
slugInput.value = slug;
updateSlugPreview();
}
generateSlugBtn.addEventListener('click', (e) => {
e.preventDefault();
generateRandomSlug();
});
// Load existing list data if editing
document.addEventListener('DOMContentLoaded', () => {
if (!isAuthenticated()) {
window.location.href = "{{ url_for('login') }}";
return;
}
const editData = sessionStorage.getItem('editList');
if (editData) {
const list = JSON.parse(editData);
editingListId = list.id;
// Update page title
document.querySelector('h1').textContent = 'Edit List';
document.querySelector('button[type="submit"]').textContent = '✓ Update List';
// Populate form - strip https:// from URLs for display
nameInput.value = list.name;
slugInput.value = list.unique_slug;
const urlsWithoutProtocol = list.urls
.split('\n')
.map(url => url.replace(/^https?:\/\//, '').trim())
.join('\n');
urlInput.value = urlsWithoutProtocol;
// Set is_public checkbox
document.getElementById('isPublic').checked = list.is_public || false;
// Update counters
document.getElementById('nameCount').textContent = `${list.name.length}/100`;
document.getElementById('slugCount').textContent = `${list.unique_slug.length}/50`;
document.getElementById('urlCount').textContent = `${list.urls.split('\n').filter(u => u.trim()).length} URLs`;
// Update share URL preview
document.getElementById('shareUrlPreview').textContent = new URL(`list-read/${list.unique_slug}`, window.location.origin).href;
// Clear session storage
sessionStorage.removeItem('editList');
} else {
// Generate initial random slug for new lists
generateRandomSlug();
}
});
// Real-time character counter for name
nameInput.addEventListener('input', (e) => {
document.getElementById('nameCount').textContent = `${e.target.value.length}/100`;
document.getElementById('nameError').classList.add('hidden');
});
// Real-time slug validation and URL preview
slugInput.addEventListener('input', updateSlugPreview);
function updateSlugPreview() {
let slug = slugInput.value.toLowerCase();
slug = slug.replace(/[^a-z0-9_-]/g, '');
if (slug !== slugInput.value) {
slugInput.value = slug;
}
document.getElementById('slugCount').textContent = `${slug.length}/50`;
// Update share URL preview
if (slug) {
document.getElementById('shareUrlPreview').textContent = new URL(`list-read/${slug}`, window.location.origin).href;
} else {
document.getElementById('shareUrlPreview').textContent = new URL('list-read/auto-generated', window.location.origin).href;
}
document.getElementById('slugError').classList.add('hidden');
}
// Real-time URL counter
urlInput.addEventListener('input', (e) => {
const urls = e.target.value.split('\n').filter(u => u.trim());
document.getElementById('urlCount').textContent = `${urls.length} URL${urls.length !== 1 ? 's' : ''}`;
document.getElementById('urlError').classList.add('hidden');
});
// Normalize URL - strip protocol to store domain only
function normalizeUrl(url) {
url = url.trim();
// Strip any existing protocol
url = url.replace(/^https?:\/\//, '');
// Return domain only (protocol will be added when needed)
return url;
}
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
const token = localStorage.getItem('access_token');
if (!token) {
showStatus('error', 'Not authenticated. Please log in again.');
return;
}
const name = nameInput.value.trim();
let slug = slugInput.value.trim().toLowerCase();
let urls = urlInput.value.trim();
// If no custom slug provided, generate one
if (!slug) {
generateRandomSlug();
slug = slugInput.value;
}
// Validation
if (!name || name.length === 0 || name.length > 100) {
showError('nameError', 'List name must be between 1 and 100 characters');
return;
}
if (slug.length < 3 || slug.length > 50) {
showError('slugError', 'Slug must be between 3 and 50 characters');
return;
}
if (!/^[a-z0-9_-]+$/.test(slug)) {
showError('slugError', 'Slug can only contain lowercase letters, numbers, hyphens, and underscores');
return;
}
const urlList = urls.split('\n').filter(u => u.trim());
if (urlList.length === 0) {
showError('urlError', 'Please enter at least one URL');
return;
}
// Normalize, validate, deduplicate and sort URLs (store domain only, no protocol)
const normalizedUrls = new Set();
for (const url of urlList) {
const domain = url.replace(/^https?:\/\//, '').trim();
if (!domain || !/^[a-zA-Z0-9.-]+(\.[a-zA-Z]{2,})?$/.test(domain)) {
showError('urlError', `Invalid domain: ${url}`);
return;
}
normalizedUrls.add(normalizeUrl(url));
}
// Sort URLs alphabetically
urls = Array.from(normalizedUrls).sort().join('\n');
const data = {
name: name,
unique_slug: slug,
urls: urls,
is_public: document.getElementById('isPublic').checked
};
const method = editingListId ? 'PUT' : 'POST';
const endpoint = editingListId ? `/api/url-lists/${editingListId}` : '/api/url-lists/';
const successMessage = editingListId ? 'List updated successfully! Redirecting...' : 'List created successfully! Redirecting...';
try {
showStatus('loading', editingListId ? 'Updating...' : 'Creating...');
const response = await fetch(endpoint, {
method: method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.ok) {
showStatus('success', successMessage);
setTimeout(() => {
window.location.href = '{{ url_for("dashboard") }}';
}, 1500);
} else {
const error = await response.json();
showStatus('error', `Error: ${error.detail || 'Failed to save list'}`);
}
} catch (error) {
console.error('Error:', error);
showStatus('error', 'An error occurred');
}
});
function showError(elementId, message) {
const errorEl = document.getElementById(elementId);
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
function showStatus(type, message) {
const statusEl = document.getElementById('statusMessage');
const statusText = document.getElementById('statusText');
statusText.textContent = message;
statusEl.classList.remove('hidden', 'bg-green-900', 'border-green-700', 'text-green-200', 'bg-red-900', 'border-red-700', 'text-red-200', 'bg-blue-900', 'border-blue-700', 'text-blue-200');
if (type === 'success') {
statusEl.classList.add('bg-green-900', 'border-green-700', 'text-green-200');
} else if (type === 'error') {
statusEl.classList.add('bg-red-900', 'border-red-700', 'text-red-200');
} else if (type === 'loading') {
statusEl.classList.add('bg-blue-900', 'border-blue-700', 'text-blue-200');
}
}
</script>
{% endblock %}