362 lines
14 KiB
HTML
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 github.com 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 %}
|