mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-01-03 01:15:57 +10:00
Implement tags editor widget for Django admin (#1729)
Implement a sleek inline tag editor with autocomplete and AJAX support:
- Create TagEditorWidget and InlineTagEditorWidget in core/widgets.py
- Pills display with X remove button, sorted alphabetically
- Text input with HTML5 datalist autocomplete
- Enter/Space/Comma to add tags, auto-creates if doesn't exist
- Backspace removes last tag when input is empty
- Add API endpoints in api/v1_core.py
- GET /tags/autocomplete/ - search tags by name
- POST /tags/create/ - get_or_create tag
- POST /tags/add-to-snapshot/ - add tag to snapshot via AJAX
- POST /tags/remove-from-snapshot/ - remove tag from snapshot
- Update admin_snapshots.py
- Replace FilteredSelectMultiple with TagEditorWidget in bulk actions
- Create SnapshotAdminForm with tags_editor field
- Update title_str() to render inline tag editor in list view
- Remove TagInline, use widget instead
- Add CSS styles in templates/admin/base.html
- Blue gradient pill styling matching admin theme
- Focus ring and hover states
- Compact inline variant for list view
<!-- IMPORTANT: Do not submit PRs with only formatting / PEP8 / line
length changes. -->
# Summary
<!--e.g. This PR fixes ABC or adds the ability to do XYZ...-->
# Related issues
<!-- e.g. #123 or Roadmap goal #
https://github.com/pirate/ArchiveBox/wiki/Roadmap -->
# Changes these areas
- [ ] Bugfixes
- [ ] Feature behavior
- [ ] Command line interface
- [ ] Configuration options
- [ ] Internal architecture
- [ ] Snapshot data layout on disk
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Implemented a new interactive tags editor for Django admin with
autocomplete and AJAX add/remove, replacing the old multi-select and
inline. This makes tagging snapshots faster and safer in detail, list,
and bulk actions.
- **New Features**
- TagEditorWidget and InlineTagEditorWidget with pill UI and remove
buttons, XSS-safe rendering, and delegated events.
- Keyboard support: Enter/Space/Comma to add, Backspace to remove last
when input is empty.
- Datalist autocomplete and debounced search via GET
/tags/autocomplete/.
- AJAX endpoints: POST /tags/create/, /tags/add-to-snapshot/,
/tags/remove-from-snapshot/.
- **Refactors**
- Replaced FilteredSelectMultiple with TagEditorWidget in bulk actions;
parse comma-separated tags and use bulk_create/delete for efficient
add/remove.
- Added SnapshotAdminForm with tags_editor field; saves tags
case-insensitively and fixes remove_tags matching.
- Rendered inline tag editor in list view via title_str; removed
TagInline.
- Added CSS in admin/base.html for pill styling, focus ring, and compact
inline variant.
<sup>Written for commit 0dee662f41.
Summary will update on new commits.</sup>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -300,3 +300,160 @@ def get_any(request, id: str):
|
||||
pass
|
||||
|
||||
raise HttpError(404, 'Object with given ID not found')
|
||||
|
||||
|
||||
### Tag Editor API Endpoints #########################################################################
|
||||
|
||||
class TagAutocompleteSchema(Schema):
|
||||
tags: List[dict]
|
||||
|
||||
|
||||
class TagCreateSchema(Schema):
|
||||
name: str
|
||||
|
||||
|
||||
class TagCreateResponseSchema(Schema):
|
||||
success: bool
|
||||
tag_id: int
|
||||
tag_name: str
|
||||
created: bool
|
||||
|
||||
|
||||
class TagSnapshotRequestSchema(Schema):
|
||||
snapshot_id: str
|
||||
tag_name: Optional[str] = None
|
||||
tag_id: Optional[int] = None
|
||||
|
||||
|
||||
class TagSnapshotResponseSchema(Schema):
|
||||
success: bool
|
||||
tag_id: int
|
||||
tag_name: str
|
||||
|
||||
|
||||
@router.get("/tags/autocomplete/", response=TagAutocompleteSchema, url_name="tags_autocomplete")
|
||||
def tags_autocomplete(request, q: str = ""):
|
||||
"""Return tags matching the query for autocomplete."""
|
||||
if not q:
|
||||
# Return all tags if no query (limited to 50)
|
||||
tags = Tag.objects.all().order_by('name')[:50]
|
||||
else:
|
||||
tags = Tag.objects.filter(name__icontains=q).order_by('name')[:20]
|
||||
|
||||
return {
|
||||
'tags': [{'id': tag.pk, 'name': tag.name, 'slug': tag.slug} for tag in tags]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tags/create/", response=TagCreateResponseSchema, url_name="tags_create")
|
||||
def tags_create(request, data: TagCreateSchema):
|
||||
"""Create a new tag or return existing one."""
|
||||
name = data.name.strip()
|
||||
if not name:
|
||||
raise HttpError(400, 'Tag name is required')
|
||||
|
||||
tag, created = Tag.objects.get_or_create(
|
||||
name__iexact=name,
|
||||
defaults={
|
||||
'name': name,
|
||||
'created_by': request.user if request.user.is_authenticated else None,
|
||||
}
|
||||
)
|
||||
|
||||
# If found by case-insensitive match, use that tag
|
||||
if not created:
|
||||
tag = Tag.objects.filter(name__iexact=name).first()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'tag_id': tag.pk,
|
||||
'tag_name': tag.name,
|
||||
'created': created,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tags/add-to-snapshot/", response=TagSnapshotResponseSchema, url_name="tags_add_to_snapshot")
|
||||
def tags_add_to_snapshot(request, data: TagSnapshotRequestSchema):
|
||||
"""Add a tag to a snapshot. Creates the tag if it doesn't exist."""
|
||||
# Get the snapshot
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(
|
||||
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
|
||||
)
|
||||
except Snapshot.DoesNotExist:
|
||||
raise HttpError(404, 'Snapshot not found')
|
||||
except Snapshot.MultipleObjectsReturned:
|
||||
snapshot = Snapshot.objects.filter(
|
||||
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
|
||||
).first()
|
||||
|
||||
# Get or create the tag
|
||||
if data.tag_name:
|
||||
name = data.tag_name.strip()
|
||||
if not name:
|
||||
raise HttpError(400, 'Tag name is required')
|
||||
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name__iexact=name,
|
||||
defaults={
|
||||
'name': name,
|
||||
'created_by': request.user if request.user.is_authenticated else None,
|
||||
}
|
||||
)
|
||||
# If found by case-insensitive match, use that tag
|
||||
tag = Tag.objects.filter(name__iexact=name).first() or tag
|
||||
elif data.tag_id:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=data.tag_id)
|
||||
except Tag.DoesNotExist:
|
||||
raise HttpError(404, 'Tag not found')
|
||||
else:
|
||||
raise HttpError(400, 'Either tag_name or tag_id is required')
|
||||
|
||||
# Add the tag to the snapshot
|
||||
snapshot.tags.add(tag)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'tag_id': tag.pk,
|
||||
'tag_name': tag.name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tags/remove-from-snapshot/", response=TagSnapshotResponseSchema, url_name="tags_remove_from_snapshot")
|
||||
def tags_remove_from_snapshot(request, data: TagSnapshotRequestSchema):
|
||||
"""Remove a tag from a snapshot."""
|
||||
# Get the snapshot
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(
|
||||
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
|
||||
)
|
||||
except Snapshot.DoesNotExist:
|
||||
raise HttpError(404, 'Snapshot not found')
|
||||
except Snapshot.MultipleObjectsReturned:
|
||||
snapshot = Snapshot.objects.filter(
|
||||
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
|
||||
).first()
|
||||
|
||||
# Get the tag
|
||||
if data.tag_id:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=data.tag_id)
|
||||
except Tag.DoesNotExist:
|
||||
raise HttpError(404, 'Tag not found')
|
||||
elif data.tag_name:
|
||||
try:
|
||||
tag = Tag.objects.get(name__iexact=data.tag_name.strip())
|
||||
except Tag.DoesNotExist:
|
||||
raise HttpError(404, 'Tag not found')
|
||||
else:
|
||||
raise HttpError(400, 'Either tag_name or tag_id is required')
|
||||
|
||||
# Remove the tag from the snapshot
|
||||
snapshot.tags.remove(tag)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'tag_id': tag.pk,
|
||||
'tag_name': tag.name,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ from django.utils import timezone
|
||||
from django import forms
|
||||
from django.template import Template, RequestContext
|
||||
from django.contrib.admin.helpers import ActionForm
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
|
||||
from archivebox.config import DATA_DIR
|
||||
from archivebox.config.common import SERVER_CONFIG
|
||||
@@ -24,8 +23,8 @@ from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
|
||||
from archivebox.workers.tasks import bg_archive_snapshots, bg_add
|
||||
|
||||
from archivebox.core.models import Tag, Snapshot
|
||||
from archivebox.core.admin_tags import TagInline
|
||||
from archivebox.core.admin_archiveresults import ArchiveResultInline, render_archiveresults_list
|
||||
from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget
|
||||
|
||||
|
||||
# GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': [], 'CAN_UPGRADE': False}
|
||||
@@ -36,16 +35,30 @@ class SnapshotActionForm(ActionForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Define tags field in __init__ to avoid database access during app initialization
|
||||
self.fields['tags'] = forms.ModelMultipleChoiceField(
|
||||
self.fields['tags'] = forms.CharField(
|
||||
label='Edit tags',
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
widget=FilteredSelectMultiple(
|
||||
'core_tag__name',
|
||||
False,
|
||||
),
|
||||
widget=TagEditorWidget(),
|
||||
)
|
||||
|
||||
def clean_tags(self):
|
||||
"""Parse comma-separated tag names into Tag objects."""
|
||||
tags_str = self.cleaned_data.get('tags', '')
|
||||
if not tags_str:
|
||||
return []
|
||||
|
||||
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
|
||||
tags = []
|
||||
for name in tag_names:
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name__iexact=name,
|
||||
defaults={'name': name}
|
||||
)
|
||||
# Use the existing tag if found by case-insensitive match
|
||||
tag = Tag.objects.filter(name__iexact=name).first() or tag
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
# TODO: allow selecting actions for specific extractor plugins? is this useful?
|
||||
# plugin = forms.ChoiceField(
|
||||
# choices=ArchiveResult.PLUGIN_CHOICES,
|
||||
@@ -54,10 +67,59 @@ class SnapshotActionForm(ActionForm):
|
||||
# )
|
||||
|
||||
|
||||
class SnapshotAdminForm(forms.ModelForm):
|
||||
"""Custom form for Snapshot admin with tag editor widget."""
|
||||
tags_editor = forms.CharField(
|
||||
label='Tags',
|
||||
required=False,
|
||||
widget=TagEditorWidget(),
|
||||
help_text='Type tag names and press Enter or Space to add. Click × to remove.',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Snapshot
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Initialize tags_editor with current tags
|
||||
if self.instance and self.instance.pk:
|
||||
self.initial['tags_editor'] = ','.join(
|
||||
sorted(tag.name for tag in self.instance.tags.all())
|
||||
)
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Handle tags_editor field
|
||||
if commit:
|
||||
instance.save()
|
||||
self._save_m2m()
|
||||
|
||||
# Parse and save tags from tags_editor
|
||||
tags_str = self.cleaned_data.get('tags_editor', '')
|
||||
if tags_str:
|
||||
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
|
||||
tags = []
|
||||
for name in tag_names:
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name__iexact=name,
|
||||
defaults={'name': name}
|
||||
)
|
||||
tag = Tag.objects.filter(name__iexact=name).first() or tag
|
||||
tags.append(tag)
|
||||
instance.tags.set(tags)
|
||||
else:
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
form = SnapshotAdminForm
|
||||
list_display = ('created_at', 'title_str', 'status', 'files', 'size', 'url_str')
|
||||
sort_fields = ('title_str', 'url_str', 'created_at', 'status', 'crawl')
|
||||
readonly_fields = ('admin_actions', 'status_info', 'tags_str', 'imported_timestamp', 'created_at', 'modified_at', 'downloaded_at', 'output_dir', 'archiveresults_list')
|
||||
readonly_fields = ('admin_actions', 'status_info', 'imported_timestamp', 'created_at', 'modified_at', 'downloaded_at', 'output_dir', 'archiveresults_list')
|
||||
search_fields = ('id', 'url', 'timestamp', 'title', 'tags__name')
|
||||
list_filter = ('created_at', 'downloaded_at', 'archiveresult__status', 'crawl__created_by', 'tags__name')
|
||||
|
||||
@@ -66,6 +128,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
'fields': ('url', 'title'),
|
||||
'classes': ('card', 'wide'),
|
||||
}),
|
||||
('Tags', {
|
||||
'fields': ('tags_editor',),
|
||||
'classes': ('card',),
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('status', 'retry_at', 'status_info'),
|
||||
'classes': ('card',),
|
||||
@@ -75,7 +141,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
'classes': ('card',),
|
||||
}),
|
||||
('Relations', {
|
||||
'fields': ('crawl', 'tags_str'),
|
||||
'fields': ('crawl',),
|
||||
'classes': ('card',),
|
||||
}),
|
||||
('Config', {
|
||||
@@ -98,7 +164,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
ordering = ['-created_at']
|
||||
actions = ['add_tags', 'remove_tags', 'update_titles', 'update_snapshots', 'resnapshot_snapshot', 'overwrite_snapshots', 'delete_snapshots']
|
||||
inlines = [TagInline] # Removed ArchiveResultInline, using custom renderer instead
|
||||
inlines = [] # Removed TagInline, using TagEditorWidget instead
|
||||
list_per_page = min(max(5, SERVER_CONFIG.SNAPSHOTS_PER_PAGE), 5000)
|
||||
|
||||
action_form = SnapshotActionForm
|
||||
@@ -257,11 +323,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
ordering='title',
|
||||
)
|
||||
def title_str(self, obj):
|
||||
tags = ''.join(
|
||||
format_html('<a href="/admin/core/snapshot/?tags__id__exact={}"><span class="tag">{}</span></a> ', tag.pk, tag.name)
|
||||
for tag in obj.tags.all()
|
||||
if str(tag.name).strip()
|
||||
# Render inline tag editor widget
|
||||
widget = InlineTagEditorWidget(snapshot_id=str(obj.pk))
|
||||
tags_html = widget.render(
|
||||
name=f'tags_{obj.pk}',
|
||||
value=obj.tags.all(),
|
||||
attrs={'id': f'tags_{obj.pk}'},
|
||||
snapshot_id=str(obj.pk),
|
||||
)
|
||||
|
||||
# Show title if available, otherwise show URL
|
||||
display_text = obj.title or obj.url
|
||||
css_class = 'fetched' if obj.title else 'pending'
|
||||
@@ -278,7 +348,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
obj.archive_path,
|
||||
css_class,
|
||||
urldecode(htmldecode(display_text))[:128]
|
||||
) + mark_safe(f' <span class="tags">{tags}</span>')
|
||||
) + mark_safe(f' <span class="tags-inline-editor">{tags_html}</span>')
|
||||
|
||||
@admin.display(
|
||||
description='Files Saved',
|
||||
@@ -428,13 +498,41 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
description="+"
|
||||
)
|
||||
def add_tags(self, request, queryset):
|
||||
tags = request.POST.getlist('tags')
|
||||
print('[+] Adding tags', tags, 'to Snapshots', queryset)
|
||||
for obj in queryset:
|
||||
obj.tags.add(*tags)
|
||||
from archivebox.core.models import SnapshotTag
|
||||
|
||||
# Get tags from the form - now comma-separated string
|
||||
tags_str = request.POST.get('tags', '')
|
||||
if not tags_str:
|
||||
messages.warning(request, "No tags specified.")
|
||||
return
|
||||
|
||||
# Parse comma-separated tag names and get/create Tag objects
|
||||
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
|
||||
tags = []
|
||||
for name in tag_names:
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name__iexact=name,
|
||||
defaults={'name': name}
|
||||
)
|
||||
tag = Tag.objects.filter(name__iexact=name).first() or tag
|
||||
tags.append(tag)
|
||||
|
||||
# Get snapshot IDs efficiently (works with select_across for all pages)
|
||||
snapshot_ids = list(queryset.values_list('id', flat=True))
|
||||
num_snapshots = len(snapshot_ids)
|
||||
|
||||
print('[+] Adding tags', [t.name for t in tags], 'to', num_snapshots, 'Snapshots')
|
||||
|
||||
# Bulk create M2M relationships (1 query per tag, not per snapshot)
|
||||
for tag in tags:
|
||||
SnapshotTag.objects.bulk_create(
|
||||
[SnapshotTag(snapshot_id=sid, tag=tag) for sid in snapshot_ids],
|
||||
ignore_conflicts=True # Skip if relationship already exists
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Added {len(tags)} tags to {queryset.count()} Snapshots.",
|
||||
f"Added {len(tags)} tag(s) to {num_snapshots} Snapshot(s).",
|
||||
)
|
||||
|
||||
|
||||
@@ -442,11 +540,40 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
description="–"
|
||||
)
|
||||
def remove_tags(self, request, queryset):
|
||||
tags = request.POST.getlist('tags')
|
||||
print('[-] Removing tags', tags, 'to Snapshots', queryset)
|
||||
for obj in queryset:
|
||||
obj.tags.remove(*tags)
|
||||
from archivebox.core.models import SnapshotTag
|
||||
|
||||
# Get tags from the form - now comma-separated string
|
||||
tags_str = request.POST.get('tags', '')
|
||||
if not tags_str:
|
||||
messages.warning(request, "No tags specified.")
|
||||
return
|
||||
|
||||
# Parse comma-separated tag names and find matching Tag objects (case-insensitive)
|
||||
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
|
||||
tags = []
|
||||
for name in tag_names:
|
||||
tag = Tag.objects.filter(name__iexact=name).first()
|
||||
if tag:
|
||||
tags.append(tag)
|
||||
|
||||
if not tags:
|
||||
messages.warning(request, "No matching tags found.")
|
||||
return
|
||||
|
||||
# Get snapshot IDs efficiently (works with select_across for all pages)
|
||||
snapshot_ids = list(queryset.values_list('id', flat=True))
|
||||
num_snapshots = len(snapshot_ids)
|
||||
tag_ids = [t.pk for t in tags]
|
||||
|
||||
print('[-] Removing tags', [t.name for t in tags], 'from', num_snapshots, 'Snapshots')
|
||||
|
||||
# Bulk delete M2M relationships (1 query total, not per snapshot)
|
||||
deleted_count, _ = SnapshotTag.objects.filter(
|
||||
snapshot_id__in=snapshot_ids,
|
||||
tag_id__in=tag_ids
|
||||
).delete()
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f"Removed {len(tags)} tags from {queryset.count()} Snapshots.",
|
||||
f"Removed {len(tags)} tag(s) from {num_snapshots} Snapshot(s) ({deleted_count} associations deleted).",
|
||||
)
|
||||
|
||||
512
archivebox/core/widgets.py
Normal file
512
archivebox/core/widgets.py
Normal file
@@ -0,0 +1,512 @@
|
||||
__package__ = 'archivebox.core'
|
||||
|
||||
import json
|
||||
from django import forms
|
||||
from django.utils.html import escape
|
||||
|
||||
|
||||
class TagEditorWidget(forms.Widget):
|
||||
"""
|
||||
A widget that renders tags as clickable pills with inline editing.
|
||||
- Displays existing tags alphabetically as styled pills with X remove button
|
||||
- Text input with HTML5 datalist for autocomplete suggestions
|
||||
- Press Enter or Space to create new tags (auto-creates if doesn't exist)
|
||||
- Uses AJAX for autocomplete and tag creation
|
||||
"""
|
||||
template_name = None # We render manually
|
||||
|
||||
class Media:
|
||||
css = {'all': []}
|
||||
js = []
|
||||
|
||||
def __init__(self, attrs=None, snapshot_id=None):
|
||||
self.snapshot_id = snapshot_id
|
||||
super().__init__(attrs)
|
||||
|
||||
def _escape(self, value):
|
||||
"""Escape HTML entities in value."""
|
||||
return escape(str(value)) if value else ''
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
"""
|
||||
Render the tag editor widget.
|
||||
|
||||
Args:
|
||||
name: Field name
|
||||
value: Can be:
|
||||
- QuerySet of Tag objects (from M2M field)
|
||||
- List of tag names
|
||||
- Comma-separated string of tag names
|
||||
- None
|
||||
attrs: HTML attributes
|
||||
renderer: Not used
|
||||
"""
|
||||
# Parse value to get list of tag names
|
||||
tags = []
|
||||
if value:
|
||||
if hasattr(value, 'all'): # QuerySet
|
||||
tags = sorted([tag.name for tag in value.all()])
|
||||
elif isinstance(value, (list, tuple)):
|
||||
if value and hasattr(value[0], 'name'): # List of Tag objects
|
||||
tags = sorted([tag.name for tag in value])
|
||||
else: # List of strings or IDs
|
||||
# Could be tag IDs from form submission
|
||||
from archivebox.core.models import Tag
|
||||
tag_names = []
|
||||
for v in value:
|
||||
if isinstance(v, str) and not v.isdigit():
|
||||
tag_names.append(v)
|
||||
else:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=v)
|
||||
tag_names.append(tag.name)
|
||||
except (Tag.DoesNotExist, ValueError):
|
||||
if isinstance(v, str):
|
||||
tag_names.append(v)
|
||||
tags = sorted(tag_names)
|
||||
elif isinstance(value, str):
|
||||
tags = sorted([t.strip() for t in value.split(',') if t.strip()])
|
||||
|
||||
widget_id = attrs.get('id', name) if attrs else name
|
||||
|
||||
# Build pills HTML
|
||||
pills_html = ''
|
||||
for tag in tags:
|
||||
pills_html += f'''
|
||||
<span class="tag-pill" data-tag="{self._escape(tag)}">
|
||||
{self._escape(tag)}
|
||||
<button type="button" class="tag-remove-btn" data-tag-name="{self._escape(tag)}">×</button>
|
||||
</span>
|
||||
'''
|
||||
|
||||
# Build the widget HTML
|
||||
html = f'''
|
||||
<div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
|
||||
<div id="{widget_id}_pills" class="tag-pills">
|
||||
{pills_html}
|
||||
</div>
|
||||
<input type="text"
|
||||
id="{widget_id}_input"
|
||||
class="tag-inline-input"
|
||||
list="{widget_id}_datalist"
|
||||
placeholder="Add tag..."
|
||||
autocomplete="off"
|
||||
onkeydown="handleTagKeydown_{widget_id}(event)"
|
||||
oninput="fetchTagAutocomplete_{widget_id}(this.value)"
|
||||
>
|
||||
<datalist id="{widget_id}_datalist"></datalist>
|
||||
<input type="hidden" name="{name}" id="{widget_id}" value="{self._escape(','.join(tags))}">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
var currentTags_{widget_id} = {json.dumps(tags)};
|
||||
var autocompleteTimeout_{widget_id} = null;
|
||||
|
||||
window.focusTagInput_{widget_id} = function(event) {{
|
||||
if (event.target.classList.contains('tag-remove-btn')) return;
|
||||
document.getElementById('{widget_id}_input').focus();
|
||||
}};
|
||||
|
||||
window.updateHiddenInput_{widget_id} = function() {{
|
||||
document.getElementById('{widget_id}').value = currentTags_{widget_id}.join(',');
|
||||
}};
|
||||
|
||||
window.addTag_{widget_id} = function(tagName) {{
|
||||
tagName = tagName.trim();
|
||||
if (!tagName) return;
|
||||
|
||||
// Check if tag already exists (case-insensitive)
|
||||
var exists = currentTags_{widget_id}.some(function(t) {{
|
||||
return t.toLowerCase() === tagName.toLowerCase();
|
||||
}});
|
||||
if (exists) {{
|
||||
document.getElementById('{widget_id}_input').value = '';
|
||||
return;
|
||||
}}
|
||||
|
||||
// Add to current tags
|
||||
currentTags_{widget_id}.push(tagName);
|
||||
currentTags_{widget_id}.sort(function(a, b) {{
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
}});
|
||||
|
||||
// Rebuild pills
|
||||
rebuildPills_{widget_id}();
|
||||
updateHiddenInput_{widget_id}();
|
||||
|
||||
// Clear input
|
||||
document.getElementById('{widget_id}_input').value = '';
|
||||
|
||||
// Create tag via API if it doesn't exist (fire and forget)
|
||||
fetch('/api/v1/core/tags/create/', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
}},
|
||||
body: JSON.stringify({{ name: tagName }})
|
||||
}}).catch(function(err) {{
|
||||
console.log('Tag creation note:', err);
|
||||
}});
|
||||
}};
|
||||
|
||||
window.removeTag_{widget_id} = function(tagName) {{
|
||||
currentTags_{widget_id} = currentTags_{widget_id}.filter(function(t) {{
|
||||
return t.toLowerCase() !== tagName.toLowerCase();
|
||||
}});
|
||||
rebuildPills_{widget_id}();
|
||||
updateHiddenInput_{widget_id}();
|
||||
}};
|
||||
|
||||
window.rebuildPills_{widget_id} = function() {{
|
||||
var container = document.getElementById('{widget_id}_pills');
|
||||
container.innerHTML = '';
|
||||
currentTags_{widget_id}.forEach(function(tag) {{
|
||||
var pill = document.createElement('span');
|
||||
pill.className = 'tag-pill';
|
||||
pill.setAttribute('data-tag', tag);
|
||||
|
||||
var tagText = document.createTextNode(tag);
|
||||
pill.appendChild(tagText);
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'tag-remove-btn';
|
||||
removeBtn.setAttribute('data-tag-name', tag);
|
||||
removeBtn.innerHTML = '×';
|
||||
pill.appendChild(removeBtn);
|
||||
|
||||
container.appendChild(pill);
|
||||
}});
|
||||
}};
|
||||
|
||||
// Add event delegation for remove buttons
|
||||
document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
|
||||
if (event.target.classList.contains('tag-remove-btn')) {{
|
||||
var tagName = event.target.getAttribute('data-tag-name');
|
||||
if (tagName) {{
|
||||
removeTag_{widget_id}(tagName);
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
|
||||
window.handleTagKeydown_{widget_id} = function(event) {{
|
||||
var input = event.target;
|
||||
var value = input.value.trim();
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ' || event.key === ',') {{
|
||||
event.preventDefault();
|
||||
if (value) {{
|
||||
// Handle comma-separated values
|
||||
value.split(',').forEach(function(tag) {{
|
||||
addTag_{widget_id}(tag.trim());
|
||||
}});
|
||||
}}
|
||||
}} else if (event.key === 'Backspace' && !value && currentTags_{widget_id}.length > 0) {{
|
||||
// Remove last tag on backspace when input is empty
|
||||
var lastTag = currentTags_{widget_id}.pop();
|
||||
rebuildPills_{widget_id}();
|
||||
updateHiddenInput_{widget_id}();
|
||||
}}
|
||||
}};
|
||||
|
||||
window.fetchTagAutocomplete_{widget_id} = function(query) {{
|
||||
if (autocompleteTimeout_{widget_id}) {{
|
||||
clearTimeout(autocompleteTimeout_{widget_id});
|
||||
}}
|
||||
|
||||
autocompleteTimeout_{widget_id} = setTimeout(function() {{
|
||||
if (!query || query.length < 1) {{
|
||||
document.getElementById('{widget_id}_datalist').innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
|
||||
fetch('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query))
|
||||
.then(function(response) {{ return response.json(); }})
|
||||
.then(function(data) {{
|
||||
var datalist = document.getElementById('{widget_id}_datalist');
|
||||
datalist.innerHTML = '';
|
||||
(data.tags || []).forEach(function(tag) {{
|
||||
var option = document.createElement('option');
|
||||
option.value = tag.name;
|
||||
datalist.appendChild(option);
|
||||
}});
|
||||
}})
|
||||
.catch(function(err) {{
|
||||
console.log('Autocomplete error:', err);
|
||||
}});
|
||||
}}, 150);
|
||||
}};
|
||||
|
||||
function escapeHtml(text) {{
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}}
|
||||
|
||||
function getCSRFToken() {{
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {{
|
||||
var cookie = cookies[i].trim();
|
||||
if (cookie.startsWith('csrftoken=')) {{
|
||||
return cookie.substring('csrftoken='.length);
|
||||
}}
|
||||
}}
|
||||
// Fallback to hidden input
|
||||
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
return input ? input.value : '';
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
'''
|
||||
|
||||
return html
|
||||
|
||||
|
||||
class InlineTagEditorWidget(TagEditorWidget):
|
||||
"""
|
||||
Inline version of TagEditorWidget for use in list views.
|
||||
Includes AJAX save functionality for immediate persistence.
|
||||
"""
|
||||
|
||||
def __init__(self, attrs=None, snapshot_id=None):
|
||||
super().__init__(attrs, snapshot_id)
|
||||
self.snapshot_id = snapshot_id
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None, snapshot_id=None):
|
||||
"""Render inline tag editor with AJAX save."""
|
||||
# Use snapshot_id from __init__ or from render call
|
||||
snapshot_id = snapshot_id or self.snapshot_id
|
||||
|
||||
# Parse value to get list of tag dicts with id and name
|
||||
tags = []
|
||||
tag_data = []
|
||||
if value:
|
||||
if hasattr(value, 'all'): # QuerySet
|
||||
for tag in value.all():
|
||||
tag_data.append({'id': tag.pk, 'name': tag.name})
|
||||
tag_data.sort(key=lambda x: x['name'].lower())
|
||||
tags = [t['name'] for t in tag_data]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
if value and hasattr(value[0], 'name'):
|
||||
for tag in value:
|
||||
tag_data.append({'id': tag.pk, 'name': tag.name})
|
||||
tag_data.sort(key=lambda x: x['name'].lower())
|
||||
tags = [t['name'] for t in tag_data]
|
||||
|
||||
widget_id = f"inline_tags_{snapshot_id}" if snapshot_id else (attrs.get('id', name) if attrs else name)
|
||||
|
||||
# Build pills HTML with filter links
|
||||
pills_html = ''
|
||||
for td in tag_data:
|
||||
pills_html += f'''
|
||||
<span class="tag-pill" data-tag="{self._escape(td['name'])}" data-tag-id="{td['id']}">
|
||||
<a href="/admin/core/snapshot/?tags__id__exact={td['id']}" class="tag-link">{self._escape(td['name'])}</a>
|
||||
<button type="button" class="tag-remove-btn" data-tag-id="{td['id']}" data-tag-name="{self._escape(td['name'])}">×</button>
|
||||
</span>
|
||||
'''
|
||||
|
||||
html = f'''
|
||||
<span id="{widget_id}_container" class="tag-editor-inline" onclick="focusInlineTagInput_{widget_id}(event)">
|
||||
<span id="{widget_id}_pills" class="tag-pills-inline">
|
||||
{pills_html}
|
||||
</span>
|
||||
<input type="text"
|
||||
id="{widget_id}_input"
|
||||
class="tag-inline-input-sm"
|
||||
list="{widget_id}_datalist"
|
||||
placeholder="+"
|
||||
autocomplete="off"
|
||||
onkeydown="handleInlineTagKeydown_{widget_id}(event)"
|
||||
oninput="fetchInlineTagAutocomplete_{widget_id}(this.value)"
|
||||
onfocus="this.placeholder='add tag...'"
|
||||
onblur="this.placeholder='+'"
|
||||
>
|
||||
<datalist id="{widget_id}_datalist"></datalist>
|
||||
</span>
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
var snapshotId_{widget_id} = '{snapshot_id}';
|
||||
var currentTagData_{widget_id} = {json.dumps(tag_data)};
|
||||
var autocompleteTimeout_{widget_id} = null;
|
||||
|
||||
window.focusInlineTagInput_{widget_id} = function(event) {{
|
||||
event.stopPropagation();
|
||||
if (event.target.classList.contains('tag-remove-btn') || event.target.classList.contains('tag-link')) return;
|
||||
document.getElementById('{widget_id}_input').focus();
|
||||
}};
|
||||
|
||||
window.addInlineTag_{widget_id} = function(tagName) {{
|
||||
tagName = tagName.trim();
|
||||
if (!tagName) return;
|
||||
|
||||
// Check if tag already exists
|
||||
var exists = currentTagData_{widget_id}.some(function(t) {{
|
||||
return t.name.toLowerCase() === tagName.toLowerCase();
|
||||
}});
|
||||
if (exists) {{
|
||||
document.getElementById('{widget_id}_input').value = '';
|
||||
return;
|
||||
}}
|
||||
|
||||
// Add via API
|
||||
fetch('/api/v1/core/tags/add-to-snapshot/', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
snapshot_id: snapshotId_{widget_id},
|
||||
tag_name: tagName
|
||||
}})
|
||||
}})
|
||||
.then(function(response) {{ return response.json(); }})
|
||||
.then(function(data) {{
|
||||
if (data.success) {{
|
||||
currentTagData_{widget_id}.push({{ id: data.tag_id, name: data.tag_name }});
|
||||
currentTagData_{widget_id}.sort(function(a, b) {{
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
}});
|
||||
rebuildInlinePills_{widget_id}();
|
||||
}}
|
||||
}})
|
||||
.catch(function(err) {{
|
||||
console.error('Error adding tag:', err);
|
||||
}});
|
||||
|
||||
document.getElementById('{widget_id}_input').value = '';
|
||||
}};
|
||||
|
||||
window.removeInlineTag_{widget_id} = function(tagId) {{
|
||||
fetch('/api/v1/core/tags/remove-from-snapshot/', {{
|
||||
method: 'POST',
|
||||
headers: {{
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
}},
|
||||
body: JSON.stringify({{
|
||||
snapshot_id: snapshotId_{widget_id},
|
||||
tag_id: tagId
|
||||
}})
|
||||
}})
|
||||
.then(function(response) {{ return response.json(); }})
|
||||
.then(function(data) {{
|
||||
if (data.success) {{
|
||||
currentTagData_{widget_id} = currentTagData_{widget_id}.filter(function(t) {{
|
||||
return t.id !== tagId;
|
||||
}});
|
||||
rebuildInlinePills_{widget_id}();
|
||||
}}
|
||||
}})
|
||||
.catch(function(err) {{
|
||||
console.error('Error removing tag:', err);
|
||||
}});
|
||||
}};
|
||||
|
||||
window.rebuildInlinePills_{widget_id} = function() {{
|
||||
var container = document.getElementById('{widget_id}_pills');
|
||||
container.innerHTML = '';
|
||||
currentTagData_{widget_id}.forEach(function(td) {{
|
||||
var pill = document.createElement('span');
|
||||
pill.className = 'tag-pill';
|
||||
pill.setAttribute('data-tag', td.name);
|
||||
pill.setAttribute('data-tag-id', td.id);
|
||||
|
||||
var link = document.createElement('a');
|
||||
link.href = '/admin/core/snapshot/?tags__id__exact=' + td.id;
|
||||
link.className = 'tag-link';
|
||||
link.textContent = td.name;
|
||||
pill.appendChild(link);
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'tag-remove-btn';
|
||||
removeBtn.setAttribute('data-tag-id', td.id);
|
||||
removeBtn.setAttribute('data-tag-name', td.name);
|
||||
removeBtn.innerHTML = '×';
|
||||
pill.appendChild(removeBtn);
|
||||
|
||||
container.appendChild(pill);
|
||||
}});
|
||||
}};
|
||||
|
||||
// Add event delegation for remove buttons
|
||||
document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
|
||||
if (event.target.classList.contains('tag-remove-btn')) {{
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
var tagId = parseInt(event.target.getAttribute('data-tag-id'), 10);
|
||||
if (tagId) {{
|
||||
removeInlineTag_{widget_id}(tagId);
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
|
||||
window.handleInlineTagKeydown_{widget_id} = function(event) {{
|
||||
event.stopPropagation();
|
||||
var input = event.target;
|
||||
var value = input.value.trim();
|
||||
|
||||
if (event.key === 'Enter' || event.key === ',') {{
|
||||
event.preventDefault();
|
||||
if (value) {{
|
||||
value.split(',').forEach(function(tag) {{
|
||||
addInlineTag_{widget_id}(tag.trim());
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
}};
|
||||
|
||||
window.fetchInlineTagAutocomplete_{widget_id} = function(query) {{
|
||||
if (autocompleteTimeout_{widget_id}) {{
|
||||
clearTimeout(autocompleteTimeout_{widget_id});
|
||||
}}
|
||||
|
||||
autocompleteTimeout_{widget_id} = setTimeout(function() {{
|
||||
if (!query || query.length < 1) {{
|
||||
document.getElementById('{widget_id}_datalist').innerHTML = '';
|
||||
return;
|
||||
}}
|
||||
|
||||
fetch('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query))
|
||||
.then(function(response) {{ return response.json(); }})
|
||||
.then(function(data) {{
|
||||
var datalist = document.getElementById('{widget_id}_datalist');
|
||||
datalist.innerHTML = '';
|
||||
(data.tags || []).forEach(function(tag) {{
|
||||
var option = document.createElement('option');
|
||||
option.value = tag.name;
|
||||
datalist.appendChild(option);
|
||||
}});
|
||||
}})
|
||||
.catch(function(err) {{
|
||||
console.log('Autocomplete error:', err);
|
||||
}});
|
||||
}}, 150);
|
||||
}};
|
||||
|
||||
function escapeHtml(text) {{
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}}
|
||||
|
||||
function getCSRFToken() {{
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {{
|
||||
var cookie = cookies[i].trim();
|
||||
if (cookie.startsWith('csrftoken=')) {{
|
||||
return cookie.substring('csrftoken='.length);
|
||||
}}
|
||||
}}
|
||||
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
return input ? input.value : '';
|
||||
}}
|
||||
}})();
|
||||
</script>
|
||||
'''
|
||||
|
||||
return html
|
||||
@@ -1059,6 +1059,189 @@
|
||||
color: #2563eb;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tag Editor Widget Styles
|
||||
============================================ */
|
||||
|
||||
/* Main container - acts as input field */
|
||||
.tag-editor-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
min-height: 42px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-editor-container:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Pills container */
|
||||
.tag-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Individual tag pill */
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px 4px 10px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 16px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.tag-pill:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
|
||||
.tag-pill a.tag-link {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag-pill a.tag-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Remove button on pills */
|
||||
.tag-remove-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-remove-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Inline input for adding tags */
|
||||
.tag-inline-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 4px 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tag-inline-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Inline editor for list view - more compact */
|
||||
.tag-editor-inline {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: text;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tag-pills-inline {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-pill {
|
||||
padding: 2px 6px 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-remove-btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-inline-input-sm {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
max-width: 100px;
|
||||
padding: 2px 4px;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-inline-input-sm:focus {
|
||||
width: 80px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tag-inline-input-sm::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Container in list view title column */
|
||||
.tags-inline-editor {
|
||||
display: inline;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Existing tag styles (keep for backwards compat) */
|
||||
.tags .tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin: 1px 2px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tags .tag:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user