mirror of
https://github.com/glanceapp/glance.git
synced 2024-12-15 17:42:31 +03:00
commit
a5be460c2e
@ -19,6 +19,7 @@
|
||||
* GitHub releases
|
||||
* Repository overview
|
||||
* Site monitor
|
||||
* Search box
|
||||
|
||||
#### Themeable
|
||||
![multiple color schemes example](docs/images/themes-example.png)
|
||||
|
@ -11,6 +11,7 @@
|
||||
- [Videos](#videos)
|
||||
- [Hacker News](#hacker-news)
|
||||
- [Reddit](#reddit)
|
||||
- [Search](#search-widget)
|
||||
- [Weather](#weather)
|
||||
- [Monitor](#monitor)
|
||||
- [Releases](#releases)
|
||||
@ -641,6 +642,80 @@ Can be used to specify an additional sort which will be applied on top of the al
|
||||
|
||||
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
|
||||
|
||||
### Search Widget
|
||||
Display a search bar that can be used to search for specific terms on various search engines.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- type: search
|
||||
search-engine: duckduckgo
|
||||
bangs:
|
||||
- title: YouTube
|
||||
shortcut: "!yt"
|
||||
url: https://www.youtube.com/results?search_query={QUERY}
|
||||
```
|
||||
|
||||
Preview:
|
||||
|
||||
![](images/search-widget-preview.png)
|
||||
|
||||
#### Keyboard shortcuts
|
||||
| Keys | Action | Condition |
|
||||
| ---- | ------ | --------- |
|
||||
| <kbd>S</kbd> | Focus the search bar | Not already focused on another input field |
|
||||
| <kbd>Enter</kbd> | Perform search in the same tab | Search input is focused and not empty |
|
||||
| <kbd>Ctrl</kbd> + <kbd>Enter</kbd> | Perform search in a new tab | Search input is focused and not empty |
|
||||
| <kbd>Escape</kbd> | Leave focus | Search input is focused |
|
||||
|
||||
#### Properties
|
||||
| Name | Type | Required | Default |
|
||||
| ---- | ---- | -------- | ------- |
|
||||
| search-engine | string | no | duckduckgo |
|
||||
| bangs | array | no | |
|
||||
|
||||
##### `search-engine`
|
||||
Either a value from the table below or a URL to a custom search engine. Use `{QUERY}` to indicate where the query value gets placed.
|
||||
|
||||
| Name | URL |
|
||||
| ---- | --- |
|
||||
| duckduckgo | `https://duckduckgo.com/?q={QUERY}` |
|
||||
| google | `https://www.google.com/search?q={QUERY}` |
|
||||
|
||||
##### `bangs`
|
||||
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
|
||||
|
||||
![](images/search-widget-bangs-preview.png)
|
||||
|
||||
##### Properties for each bang
|
||||
| Name | Type | Required |
|
||||
| ---- | ---- | -------- |
|
||||
| title | string | no |
|
||||
| shortcut | string | yes |
|
||||
| url | string | yes |
|
||||
|
||||
###### `title`
|
||||
Optional title that will appear on the right side of the search bar when the query starts with the associated shortcut.
|
||||
|
||||
###### `shortcut`
|
||||
Any value you wish to use as the shortcut for the search engine. It does not have to start with `!`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> In YAML some characters have special meaning when placed in the beginning of a value. If your shortcut starts with `!` (and potentially some other special characters) you'll have to wrap the value in quotes:
|
||||
> ```yaml
|
||||
> shortcut: "!yt"
|
||||
>```
|
||||
|
||||
###### `url`
|
||||
The URL of the search engine. Use `{QUERY}` to indicate where the query value gets placed. Examples:
|
||||
|
||||
```yaml
|
||||
url: https://www.reddit.com/search?q={QUERY}
|
||||
url: https://store.steampowered.com/search/?term={QUERY}
|
||||
url: https://www.amazon.com/s?k={QUERY}
|
||||
```
|
||||
|
||||
### Weather
|
||||
Display weather information for a specific location. The data is provided by https://open-meteo.com/.
|
||||
|
||||
|
BIN
docs/images/search-widget-bangs-preview.png
Normal file
BIN
docs/images/search-widget-bangs-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.5 KiB |
BIN
docs/images/search-widget-preview.png
Normal file
BIN
docs/images/search-widget-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.8 KiB |
@ -354,6 +354,23 @@ body {
|
||||
border: 1px solid var(--color-negative);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font: inherit;
|
||||
padding: 0.1rem 0.8rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 2px solid var(--color-widget-background-highlight);
|
||||
box-shadow: 0 2px 0 var(--color-widget-background-highlight);
|
||||
user-select: none;
|
||||
transition: transform .1s, box-shadow .1s;
|
||||
font-size: var(--font-size-h5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
kbd:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 0 0 0 var(--color-widget-background-highlight);
|
||||
}
|
||||
|
||||
.content-bounds {
|
||||
max-width: 1600px;
|
||||
margin-inline: auto;
|
||||
@ -665,6 +682,85 @@ body {
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 2.3rem;
|
||||
}
|
||||
|
||||
.search-icon-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* gives a wider hit area for the 3 people that will notice the animation : ) */
|
||||
.search-icon-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1rem;
|
||||
}
|
||||
|
||||
.search-icon-container:hover > .search-icon {
|
||||
animation: searchIconHover 2.9s forwards;
|
||||
}
|
||||
|
||||
@keyframes searchIconHover {
|
||||
0%, 39% { translate: 0 0; }
|
||||
20% { scale: 1.3; }
|
||||
40% { scale: 1; }
|
||||
50% { translate: -30% 30%; }
|
||||
70% { translate: 30% -30%; }
|
||||
90% { translate: -30% -30%; }
|
||||
100% { translate: 0 0; }
|
||||
}
|
||||
|
||||
.search {
|
||||
transition: border-color .2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search:hover {
|
||||
border-color: var(--color-text-subdue);
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: 0;
|
||||
background: none;
|
||||
width: 100%;
|
||||
height: 6rem;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-base-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-bangs { display: none; }
|
||||
|
||||
.search-bang {
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background: var(--color-widget-background-highlight);
|
||||
padding: 0.3rem 1rem;
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-h5);
|
||||
animation: searchBangsEntrance .3s cubic-bezier(0.25, 1, 0.5, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes searchBangsEntrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.search-bang:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.forum-post-list-item {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
|
@ -107,6 +107,103 @@ function updateRelativeTimeForElements(elements)
|
||||
}
|
||||
}
|
||||
|
||||
function setupSearchboxes() {
|
||||
const searchWidgets = document.getElementsByClassName("search");
|
||||
|
||||
if (searchWidgets.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < searchWidgets.length; i++) {
|
||||
const widget = searchWidgets[i];
|
||||
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
|
||||
const inputElement = widget.getElementsByClassName("search-input")[0];
|
||||
const bangElement = widget.getElementsByClassName("search-bang")[0];
|
||||
const bangs = widget.querySelectorAll(".search-bangs > input");
|
||||
const bangsMap = {};
|
||||
const kbdElement = widget.getElementsByTagName("kbd")[0];
|
||||
let currentBang = null;
|
||||
|
||||
for (let j = 0; j < bangs.length; j++) {
|
||||
const bang = bangs[j];
|
||||
bangsMap[bang.dataset.shortcut] = bang;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key == "Escape") {
|
||||
inputElement.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key == "Enter") {
|
||||
const input = inputElement.value.trim();
|
||||
let query;
|
||||
let searchUrlTemplate;
|
||||
|
||||
if (currentBang != null) {
|
||||
query = input.slice(currentBang.dataset.shortcut.length + 1);
|
||||
searchUrlTemplate = currentBang.dataset.url;
|
||||
} else {
|
||||
query = input;
|
||||
searchUrlTemplate = defaultSearchUrl;
|
||||
}
|
||||
|
||||
if (query.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
|
||||
|
||||
if (event.ctrlKey) {
|
||||
window.open(url, '_blank').focus();
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const changeCurrentBang = (bang) => {
|
||||
currentBang = bang;
|
||||
bangElement.textContent = bang != null ? bang.dataset.title : "";
|
||||
}
|
||||
|
||||
const handleInput = (event) => {
|
||||
const value = event.target.value.trimStart();
|
||||
const words = value.split(" ");
|
||||
|
||||
if (words.length >= 2 && words[0] in bangsMap) {
|
||||
changeCurrentBang(bangsMap[words[0]]);
|
||||
return;
|
||||
}
|
||||
|
||||
changeCurrentBang(null);
|
||||
};
|
||||
|
||||
inputElement.addEventListener("focus", () => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("input", handleInput);
|
||||
});
|
||||
inputElement.addEventListener("blur", () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("input", handleInput);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||
if (event.key != "s") return;
|
||||
|
||||
inputElement.focus();
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
kbdElement.addEventListener("mousedown", () => {
|
||||
requestAnimationFrame(() => inputElement.focus());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupDynamicRelativeTime() {
|
||||
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
|
||||
const updateInterval = 60 * 1000;
|
||||
@ -454,6 +551,7 @@ async function setupPage() {
|
||||
try {
|
||||
setupClocks()
|
||||
setupCarousels();
|
||||
setupSearchboxes();
|
||||
setupCollapsibleLists();
|
||||
setupCollapsibleGrids();
|
||||
setupDynamicRelativeTime();
|
||||
|
@ -34,6 +34,7 @@ var (
|
||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
|
24
internal/assets/templates/search.html
Normal file
24
internal/assets/templates/search.html
Normal file
@ -0,0 +1,24 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}">
|
||||
<div class="search-bangs">
|
||||
{{ range .Bangs }}
|
||||
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="search-icon-container">
|
||||
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off">
|
||||
|
||||
<div class="search-bang"></div>
|
||||
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
|
||||
</div>
|
||||
{{ end }}
|
66
internal/widget/search.go
Normal file
66
internal/widget/search.go
Normal file
@ -0,0 +1,66 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/glanceapp/glance/internal/assets"
|
||||
)
|
||||
|
||||
type SearchBang struct {
|
||||
Title string
|
||||
Shortcut string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Search struct {
|
||||
widgetBase `yaml:",inline"`
|
||||
cachedHTML template.HTML `yaml:"-"`
|
||||
SearchEngine string `yaml:"search-engine"`
|
||||
Bangs []SearchBang `yaml:"bangs"`
|
||||
}
|
||||
|
||||
func convertSearchUrl(url string) string {
|
||||
// Go's template is being stubborn and continues to escape the curlies in the
|
||||
// URL regardless of what the type of the variable is so this is my way around it
|
||||
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
|
||||
}
|
||||
|
||||
var searchEngines = map[string]string{
|
||||
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
|
||||
"google": "https://www.google.com/search?q={QUERY}",
|
||||
}
|
||||
|
||||
func (widget *Search) Initialize() error {
|
||||
widget.withTitle("Search").withError(nil)
|
||||
|
||||
if widget.SearchEngine == "" {
|
||||
widget.SearchEngine = "duckduckgo"
|
||||
}
|
||||
|
||||
if url, ok := searchEngines[widget.SearchEngine]; ok {
|
||||
widget.SearchEngine = url
|
||||
}
|
||||
|
||||
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
|
||||
|
||||
for i := range widget.Bangs {
|
||||
if widget.Bangs[i].Shortcut == "" {
|
||||
return fmt.Errorf("Search bang %d has no shortcut", i+1)
|
||||
}
|
||||
|
||||
if widget.Bangs[i].URL == "" {
|
||||
return fmt.Errorf("Search bang %d has no URL", i+1)
|
||||
}
|
||||
|
||||
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
|
||||
}
|
||||
|
||||
widget.cachedHTML = widget.render(widget, assets.SearchTemplate)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (widget *Search) Render() template.HTML {
|
||||
return widget.cachedHTML
|
||||
}
|
@ -47,6 +47,8 @@ func New(widgetType string) (Widget, error) {
|
||||
return &TwitchChannels{}, nil
|
||||
case "repository":
|
||||
return &Repository{}, nil
|
||||
case "search":
|
||||
return &Search{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user