Add search autocomplete (#53)
* Implement search autocomplete for recent searches * Implement search autocomplete for bookmarks * Fix URL encoding of query param * Add tag suggestions to search autocomplete Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
This commit is contained in:
parent
816a887d99
commit
c13b27e170
2
API.md
2
API.md
@ -45,6 +45,8 @@ Example response:
|
|||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"title": "Example title",
|
"title": "Example title",
|
||||||
"description": "Example description",
|
"description": "Example description",
|
||||||
|
"website_title": "Website title",
|
||||||
|
"website_description": "Website description",
|
||||||
"tag_names": [
|
"tag_names": [
|
||||||
"tag1",
|
"tag1",
|
||||||
"tag2"
|
"tag2"
|
||||||
|
@ -12,8 +12,23 @@ class TagListField(serializers.ListField):
|
|||||||
class BookmarkSerializer(serializers.ModelSerializer):
|
class BookmarkSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Bookmark
|
model = Bookmark
|
||||||
fields = ['id', 'url', 'title', 'description', 'tag_names', 'date_added', 'date_modified']
|
fields = [
|
||||||
read_only_fields = ['date_added', 'date_modified']
|
'id',
|
||||||
|
'url',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'website_title',
|
||||||
|
'website_description',
|
||||||
|
'tag_names',
|
||||||
|
'date_added',
|
||||||
|
'date_modified'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'website_title',
|
||||||
|
'website_description',
|
||||||
|
'date_added',
|
||||||
|
'date_modified'
|
||||||
|
]
|
||||||
|
|
||||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField()
|
tag_names = TagListField()
|
||||||
|
274
bookmarks/components/SearchAutoComplete.svelte
Normal file
274
bookmarks/components/SearchAutoComplete.svelte
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<script>
|
||||||
|
import {SearchHistory} from "./SearchHistory";
|
||||||
|
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
|
||||||
|
|
||||||
|
const searchHistory = new SearchHistory()
|
||||||
|
|
||||||
|
export let name;
|
||||||
|
export let placeholder;
|
||||||
|
export let value;
|
||||||
|
export let tags;
|
||||||
|
export let apiClient;
|
||||||
|
|
||||||
|
let isFocus = false;
|
||||||
|
let isOpen = false;
|
||||||
|
let suggestions = []
|
||||||
|
let selectedIndex = undefined;
|
||||||
|
let input = null;
|
||||||
|
|
||||||
|
// Track current search query after loading the page
|
||||||
|
searchHistory.pushCurrent()
|
||||||
|
updateSuggestions()
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
isFocus = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
isFocus = false;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e) {
|
||||||
|
value = e.target.value
|
||||||
|
debouncedLoadSuggestions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
// Enter
|
||||||
|
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
|
||||||
|
const suggestion = suggestions.total[selectedIndex];
|
||||||
|
if (suggestion) completeSuggestion(suggestion);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Escape
|
||||||
|
if (e.keyCode === 27) {
|
||||||
|
close();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Up arrow
|
||||||
|
if (e.keyCode === 38) {
|
||||||
|
updateSelection(-1);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
// Down arrow
|
||||||
|
if (e.keyCode === 40) {
|
||||||
|
if (!isOpen) {
|
||||||
|
loadSuggestions()
|
||||||
|
} else {
|
||||||
|
updateSelection(1);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
isOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen = false;
|
||||||
|
updateSuggestions()
|
||||||
|
selectedIndex = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSuggestions() {
|
||||||
|
return suggestions.total.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSuggestions() {
|
||||||
|
|
||||||
|
let suggestionIndex = 0
|
||||||
|
|
||||||
|
function nextIndex() {
|
||||||
|
return suggestionIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag suggestions
|
||||||
|
let tagSuggestions = []
|
||||||
|
const currentWord = getCurrentWord(input)
|
||||||
|
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
|
||||||
|
const searchTag = currentWord.substring(1, currentWord.length)
|
||||||
|
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(tagName => ({
|
||||||
|
type: 'tag',
|
||||||
|
index: nextIndex(),
|
||||||
|
label: `#${tagName}`,
|
||||||
|
tagName: tagName
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent search suggestions
|
||||||
|
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
|
||||||
|
type: 'search',
|
||||||
|
index: nextIndex(),
|
||||||
|
label: value,
|
||||||
|
value
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Bookmark suggestions
|
||||||
|
let bookmarks = []
|
||||||
|
|
||||||
|
if (value && value.length >= 3) {
|
||||||
|
const fetchedBookmarks = await apiClient.getBookmarks(value, {limit: 5, offset: 0})
|
||||||
|
bookmarks = fetchedBookmarks.map(bookmark => {
|
||||||
|
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
|
||||||
|
const label = clampText(fullLabel, 60)
|
||||||
|
return {
|
||||||
|
type: 'bookmark',
|
||||||
|
index: nextIndex(),
|
||||||
|
label,
|
||||||
|
bookmark
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSuggestions(search, bookmarks, tagSuggestions)
|
||||||
|
|
||||||
|
if (hasSuggestions()) {
|
||||||
|
open()
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedLoadSuggestions = debounce(loadSuggestions)
|
||||||
|
|
||||||
|
function updateSuggestions(search, bookmarks, tagSuggestions) {
|
||||||
|
search = search || []
|
||||||
|
bookmarks = bookmarks || []
|
||||||
|
tagSuggestions = tagSuggestions || []
|
||||||
|
suggestions = {
|
||||||
|
search,
|
||||||
|
bookmarks,
|
||||||
|
tags: tagSuggestions,
|
||||||
|
total: [
|
||||||
|
...tagSuggestions,
|
||||||
|
...search,
|
||||||
|
...bookmarks,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeSuggestion(suggestion) {
|
||||||
|
if (suggestion.type === 'search') {
|
||||||
|
value = suggestion.value
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
if (suggestion.type === 'bookmark') {
|
||||||
|
window.open(suggestion.bookmark.url, '_blank')
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
if (suggestion.type === 'tag') {
|
||||||
|
const bounds = getCurrentWordBounds(input);
|
||||||
|
const inputValue = input.value;
|
||||||
|
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelection(dir) {
|
||||||
|
|
||||||
|
const length = suggestions.total.length;
|
||||||
|
|
||||||
|
if (length === 0) return
|
||||||
|
|
||||||
|
if (selectedIndex === undefined) {
|
||||||
|
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let newIndex = selectedIndex + dir;
|
||||||
|
|
||||||
|
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
|
||||||
|
if (newIndex >= length) newIndex = 0;
|
||||||
|
|
||||||
|
selectedIndex = newIndex;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="form-autocomplete">
|
||||||
|
<div class="form-autocomplete-input" class:is-focused={isFocus}>
|
||||||
|
<input type="search" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
|
||||||
|
bind:this={input}
|
||||||
|
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="menu" class:open={isOpen}>
|
||||||
|
{#if suggestions.tags.length > 0}
|
||||||
|
<li class="menu-item group-item">Tags</li>
|
||||||
|
{/if}
|
||||||
|
{#each suggestions.tags as suggestion}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-content">
|
||||||
|
{suggestion.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if suggestions.search.length > 0}
|
||||||
|
<li class="menu-item group-item">Recent Searches</li>
|
||||||
|
{/if}
|
||||||
|
{#each suggestions.search as suggestion}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-content">
|
||||||
|
{suggestion.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if suggestions.bookmarks.length > 0}
|
||||||
|
<li class="menu-item group-item">Bookmarks</li>
|
||||||
|
{/if}
|
||||||
|
{#each suggestions.bookmarks as suggestion}
|
||||||
|
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
|
||||||
|
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
|
||||||
|
<div class="tile tile-centered">
|
||||||
|
<div class="tile-content">
|
||||||
|
{suggestion.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu {
|
||||||
|
display: none;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-autocomplete-input {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: Should be read from theme */
|
||||||
|
.menu-item.selected > a {
|
||||||
|
background: #f1f1fc;
|
||||||
|
color: #5755d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item, .group-item:hover {
|
||||||
|
color: #999999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
48
bookmarks/components/SearchHistory.js
Normal file
48
bookmarks/components/SearchHistory.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const SEARCH_HISTORY_KEY = 'searchHistory'
|
||||||
|
const MAX_ENTRIES = 30
|
||||||
|
|
||||||
|
export class SearchHistory {
|
||||||
|
|
||||||
|
getHistory() {
|
||||||
|
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
|
||||||
|
return historyJson ? JSON.parse(historyJson) : {
|
||||||
|
recent: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCurrent() {
|
||||||
|
// Skip if browser is not compatible
|
||||||
|
if (!window.URLSearchParams) return
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const searchParam = urlParams.get('q');
|
||||||
|
|
||||||
|
if (!searchParam) return
|
||||||
|
|
||||||
|
this.push(searchParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
push(search) {
|
||||||
|
const history = this.getHistory()
|
||||||
|
|
||||||
|
history.recent.unshift(search)
|
||||||
|
|
||||||
|
// Remove duplicates and clamp to max entries
|
||||||
|
history.recent = history.recent.reduce((acc, cur) => {
|
||||||
|
if (acc.length >= MAX_ENTRIES) return acc
|
||||||
|
if (acc.indexOf(cur) >= 0) return acc
|
||||||
|
acc.push(cur)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const newHistoryJson = JSON.stringify(history)
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentSearches(query, max) {
|
||||||
|
const history = this.getHistory()
|
||||||
|
|
||||||
|
return history.recent
|
||||||
|
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
|
||||||
|
.slice(0, max)
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import {getCurrentWord, getCurrentWordBounds} from "./util";
|
||||||
|
|
||||||
export let id;
|
export let id;
|
||||||
export let name;
|
export let name;
|
||||||
export let value;
|
export let value;
|
||||||
@ -23,7 +25,7 @@
|
|||||||
function handleInput(e) {
|
function handleInput(e) {
|
||||||
input = e.target;
|
input = e.target;
|
||||||
|
|
||||||
const word = getCurrentWord();
|
const word = getCurrentWord(input);
|
||||||
|
|
||||||
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
|
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
|
||||||
|
|
||||||
@ -73,27 +75,6 @@
|
|||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentWordBounds() {
|
|
||||||
const text = input.value;
|
|
||||||
const end = input.selectionStart;
|
|
||||||
let start = end;
|
|
||||||
|
|
||||||
let currentChar = text.charAt(start - 1);
|
|
||||||
|
|
||||||
while (currentChar && currentChar !== ' ' && start > 0) {
|
|
||||||
start--;
|
|
||||||
currentChar = text.charAt(start - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {start, end};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentWord() {
|
|
||||||
const bounds = getCurrentWordBounds(input);
|
|
||||||
|
|
||||||
return input.value.substring(bounds.start, bounds.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelection(dir) {
|
function updateSelection(dir) {
|
||||||
|
|
||||||
const length = suggestions.length;
|
const length = suggestions.length;
|
||||||
|
14
bookmarks/components/api.js
Normal file
14
bookmarks/components/api.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export class ApiClient {
|
||||||
|
constructor(baseUrl) {
|
||||||
|
this.baseUrl = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
getBookmarks(query, options = {limit: 100, offset: 0}) {
|
||||||
|
const encodedQuery = encodeURIComponent(query)
|
||||||
|
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => data.results)
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,10 @@
|
|||||||
import TagAutoComplete from './TagAutocomplete.svelte'
|
import TagAutoComplete from './TagAutocomplete.svelte'
|
||||||
|
import SearchAutoComplete from './SearchAutoComplete.svelte'
|
||||||
|
import {ApiClient} from './api'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
TagAutoComplete
|
ApiClient,
|
||||||
|
TagAutoComplete,
|
||||||
|
SearchAutoComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
37
bookmarks/components/util.js
Normal file
37
bookmarks/components/util.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export function debounce(callback, delay = 250) {
|
||||||
|
let timeoutId
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timeoutId = null
|
||||||
|
callback(...args)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampText(text, maxChars = 30) {
|
||||||
|
if(!text || text.length <= 30) return text
|
||||||
|
|
||||||
|
return text.substr(0, maxChars) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWordBounds(input) {
|
||||||
|
const text = input.value;
|
||||||
|
const end = input.selectionStart;
|
||||||
|
let start = end;
|
||||||
|
|
||||||
|
let currentChar = text.charAt(start - 1);
|
||||||
|
|
||||||
|
while (currentChar && currentChar !== ' ' && start > 0) {
|
||||||
|
start--;
|
||||||
|
currentChar = text.charAt(start - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {start, end};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWord(input) {
|
||||||
|
const bounds = getCurrentWordBounds(input);
|
||||||
|
|
||||||
|
return input.value.substring(bounds.start, bounds.end);
|
||||||
|
}
|
@ -1,7 +1,12 @@
|
|||||||
.bookmarks-page {
|
.bookmarks-page {
|
||||||
|
|
||||||
.search input[type=search] {
|
.search input[type=search] {
|
||||||
width: 200px;
|
width: 180px;
|
||||||
|
height: 1.8rem;
|
||||||
|
|
||||||
|
@media (min-width: $control-width-md) {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{% extends "bookmarks/layout.html" %}
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
{% load static %}
|
||||||
{% load shared %}
|
{% load shared %}
|
||||||
{% load bookmarks %}
|
{% load bookmarks %}
|
||||||
|
|
||||||
@ -13,7 +14,10 @@
|
|||||||
<div class="search">
|
<div class="search">
|
||||||
<form action="{% url 'bookmarks:index' %}" method="get">
|
<form action="{% url 'bookmarks:index' %}" method="get">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="search" name="q" placeholder="Search for words or #tags" value="{{ query }}">
|
<span id="search-input-wrap">
|
||||||
|
<input type="search" name="q" placeholder="Search for words or #tags"
|
||||||
|
value="{{ query }}">
|
||||||
|
</span>
|
||||||
<input type="submit" value="Search" class="btn input-group-btn">
|
<input type="submit" value="Search" class="btn input-group-btn">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -36,4 +40,24 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Replace search input with auto-complete component #}
|
||||||
|
<script src="{% static "bundle.js" %}"></script>
|
||||||
|
<script type="application/javascript">
|
||||||
|
const currentTagsString = '{{ tags_string }}';
|
||||||
|
const currentTags = currentTagsString.split(' ');
|
||||||
|
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
|
||||||
|
const wrapper = document.getElementById('search-input-wrap')
|
||||||
|
const newWrapper = document.createElement('div')
|
||||||
|
new linkding.SearchAutoComplete({
|
||||||
|
target: newWrapper,
|
||||||
|
props: {
|
||||||
|
name: 'q',
|
||||||
|
placeholder: 'Search for words or #tags',
|
||||||
|
value: '{{ query }}',
|
||||||
|
tags: currentTags,
|
||||||
|
apiClient
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wrapper.parentElement.replaceChild(newWrapper, wrapper)
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -22,5 +22,5 @@ urlpatterns = [
|
|||||||
path('settings/export', views.settings.bookmark_export, name='settings.export'),
|
path('settings/export', views.settings.bookmark_export, name='settings.export'),
|
||||||
# API
|
# API
|
||||||
path('api/check_url', views.api.check_url, name='api.check_url'),
|
path('api/check_url', views.api.check_url, name='api.check_url'),
|
||||||
url(r'^api/', include(router.urls))
|
path('api/', include(router.urls), name='api')
|
||||||
]
|
]
|
||||||
|
@ -22,6 +22,8 @@ def index(request):
|
|||||||
paginator = Paginator(query_set, _default_page_size)
|
paginator = Paginator(query_set, _default_page_size)
|
||||||
bookmarks = paginator.get_page(page)
|
bookmarks = paginator.get_page(page)
|
||||||
tags = queries.query_tags(request.user, query_string)
|
tags = queries.query_tags(request.user, query_string)
|
||||||
|
tag_names = [tag.name for tag in tags]
|
||||||
|
tags_string = build_tag_string(tag_names, ' ')
|
||||||
return_url = generate_index_return_url(page, query_string)
|
return_url = generate_index_return_url(page, query_string)
|
||||||
|
|
||||||
if request.GET.get('tag'):
|
if request.GET.get('tag'):
|
||||||
@ -32,6 +34,7 @@ def index(request):
|
|||||||
context = {
|
context = {
|
||||||
'bookmarks': bookmarks,
|
'bookmarks': bookmarks,
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
|
'tags_string': tags_string,
|
||||||
'query': query_string if query_string else '',
|
'query': query_string if query_string else '',
|
||||||
'empty': paginator.count == 0,
|
'empty': paginator.count == 0,
|
||||||
'return_url': return_url
|
'return_url': return_url
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/polls" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/siteroot" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/bookmarks" isTestSource="false" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
Loading…
Reference in New Issue
Block a user