yarr/src/assets/index.html
2025-03-11 11:15:09 +00:00

443 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
<link rel="manifest" href="./manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<script>
window.app = window.app || {}
window.app.settings = {% .settings %}
window.app.authenticated = {% .authenticated %}
</script>
</head>
<body class="theme-{% .settings.theme_name %}">
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
<!-- feed list -->
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
<div class="flex-grow-1"></div>
<button class="toolbar-item"
:class="{active: filterSelected == 'unread'}"
:aria-pressed="filterSelected == 'unread'"
title="Unread"
@click="filterSelected = 'unread'">
<span class="icon">{% inline "circle-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == 'starred'}"
:aria-pressed="filterSelected == 'starred'"
title="Starred"
@click="filterSelected = 'starred'">
<span class="icon">{% inline "star-full.svg" %}</span>
</button>
<button class="toolbar-item"
:class="{active: filterSelected == ''}"
:aria-pressed="filterSelected == ''"
title="All"
@click="filterSelected = ''">
<span class="icon">{% inline "assorted.svg" %}</span>
</button>
<div class="flex-grow-1"></div>
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<button class="dropdown-item" @click="showSettings('create')">
<span class="icon mr-1">{% inline "plus.svg" %}</span>
New Feed
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="fetchAllFeeds()">
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Theme</header>
<div class="row text-center m-0">
<button class="btn btn-link col-4 px-0 rounded-0"
:class="'theme-'+t"
:aria-label="t"
:aria-pressed="theme.name == t"
@click.stop="theme.name = t"
v-for="t in ['light', 'sepia', 'night']">
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
<div class="row text-center m-0">
<button class="dropdown-item col-4 px-0" :aria-pressed="!refreshRate" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 10" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 30" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 60" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 120" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
<button class="dropdown-item col-4 px-0" :aria-pressed="refreshRate == 240" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Show first</header>
<div class="d-flex text-center">
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
</div>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
<input type="file"
id="opml-import"
@change="importOPML"
name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
</label>
</form>
<a class="dropdown-item" href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
</a>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @click="showSettings('shortcuts')">
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
Shortcuts
</button>
<div class="dropdown-divider" v-if="authenticated"></div>
<button class="dropdown-item" v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
</button>
</dropdown>
</div>
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
<label class="selectgroup">
<input type="radio" name="feed" value="" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2">{% inline "layers.svg" %}</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
<span class="counter text-right">{{ filteredTotalStats }}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
<label class="selectgroup mt-1"
:class="{'d-none': filterSelected
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
&& !filteredFolderStats[folder.id]
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
<span class="icon mr-2"
:class="{expanded: folder.is_expanded}"
@click.prevent="toggleFolderExpanded(folder)">
{% inline "chevron-right.svg" %}
</span>
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
</div>
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
<label class="selectgroup"
:class="{'d-none': filterSelected
&& !(current.feed.id == feed.id)
&& !filteredFeedStats[feed.id]
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
v-for="feed in folder.feeds">
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img :src="'./api/feeds/'+feed.id+'/icon'" alt="" loading="lazy"></span>
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
<span class="icon flex-shrink-0 mx-2"
:title="feed_errors[feed.id]"
v-if="!filterSelected && feed_errors[feed.id]">
{% inline "alert-circle.svg" %}
</span>
</div>
</label>
</div>
</div>
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
</div>
</div>
<!-- item list -->
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
<div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null"
title="Show Feeds">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<div class="input-icon flex-grow-1">
<span class="icon">{% inline "search.svg" %}</span>
<!-- id used by keybindings -->
<input id="searchbar" type="" class="d-block toolbar-search" v-model="itemSearch" @keydown.enter="$event.target.blur()">
</div>
<button class="toolbar-item ml-2"
@click="markItemsRead()"
v-if="filterSelected == 'unread'"
title="Mark All Read">
<span class="icon">{% inline "check.svg" %}</span>
</button>
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</button>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
drop="right"
title="Feed Settings"
v-if="current.type == 'feed'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
<span class="icon mr-1">{% inline "globe.svg" %}</span>
Website
</a>
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "rss.svg" %}</span>
Feed Link
</a>
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
<button class="dropdown-item" @click="renameFeed(current.feed)">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename
</button>
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Change Link
</button>
<div class="dropdown-divider"></div>
<header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
<button class="dropdown-item"
v-if="folder.id != current.feed.folder_id"
v-for="folder in folders"
@click="moveFeed(current.feed, folder)">
<span class="icon mr-1">{% inline "folder.svg" %}</span>
{{ folder.title }}
</button>
<button class="dropdown-item text-muted" @click="moveFeed(current.feed, null)" v-if="current.feed.folder_id">
<span class="icon mr-1">{% inline "folder-minus.svg" %}</span>
──
</button>
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
new folder
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
<span class="icon mr-1">{% inline "trash.svg" %}</span>
Delete
</button>
</dropdown>
<dropdown class="settings-dropdown"
toggle-class="btn btn-link toolbar-item px-2 ml-2"
title="Folder Settings"
drop="right"
v-if="current.type == 'folder'">
<template v-slot:button>
<span class="icon">{% inline "more-horizontal.svg" %}</span>
</template>
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
<button class="dropdown-item" @click="renameFolder(current.folder)">
<span class="icon mr-1">{% inline "edit.svg" %}</span>
Rename
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
<span class="icon mr-1">{% inline "trash.svg" %}</span>
Delete
</button>
</dropdown>
</div>
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
<label v-for="item in items" :key="item.id"
class="selectgroup">
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
<div class="selectgroup-label d-flex flex-column">
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
<transition name="indicator">
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition>
<small class="flex-fill text-truncate mr-1">
{{ (feedsById[item.feed_id] || {}).title }}
</small>
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
</div>
<div>{{ item.title || 'untitled' }}</div>
</div>
</label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
</div>
<div class="px-3 py-2 border-top text-danger text-break" v-if="feed_errors[current.feed.id]">
{{ feed_errors[current.feed.id] }}
</div>
</div>
<!-- item show -->
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
title="Mark Starred">
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
</button>
<button class="toolbar-item"
title="Mark Unread"
@click="toggleItemRead(itemSelectedDetails)">
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
</button>
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
<template v-slot:button>
<span class="icon">{% inline "sliders.svg" %}</span>
</template>
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
<div class="d-flex text-center">
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
<button class="dropdown-item" style="font-size: 1.2rem" @click.stop="incrFont(1)">A</button>
</div>
</dropdown>
<button class="toolbar-item"
:class="{active: itemSelectedReadability}"
@click="toggleReadability()"
title="Read Here">
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
</button>
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
<span class="icon">{% inline "external-link.svg" %}</span>
</a>
<div class="flex-grow-1"></div>
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
<span class="icon">{% inline "chevron-right.svg" %}</span>
</button>
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
<span class="icon">{% inline "x.svg" %}</span>
</button>
</div>
<div v-if="itemSelectedDetails"
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
:style="{'font-size': theme.size + 'rem'}">
<div class="content-wrapper">
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
<div class="text-muted">
<div>
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
</span>
</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
</div>
<hr>
<div v-if="!itemSelectedReadability">
<div v-if="contentImages.length">
<figure v-for="media in contentImages">
<img :src="media.url" loading="lazy">
<figcaption v-if="media.description" v-html="media.description"></figcaption>
</figure>
</div>
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
</div>
<div v-html="itemSelectedContent"></div>
</div>
</div>
</div>
<modal :open="!!settings" @hide="settings = ''">
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
<span class="icon">{% inline "x.svg" %}</span>
</button>
<div v-if="settings=='create'">
<p class="cursor-default"><b>New Feed</b></p>
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
<label for="feed-url">URL</label>
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
<label for="feed-folder" class="mt-3 d-block">
Folder
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
</label>
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
<option value="">---</option>
<option :value="folder.id" v-for="folder in folders" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ folder.title }}</option>
</select>
<div class="mt-4" v-if="feedNewChoice.length">
<p class="mb-2">
Multiple feeds found. Choose one below:
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
</p>
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
<div class="selectgroup-label">
<div class="text-truncate">{{ choice.title }}</div>
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
</div>
</label>
</div>
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
</form>
</div>
<div v-else-if="settings=='shortcuts'">
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
<table class="table table-borderless table-sm table-compact m-0">
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
<td>show unread / starred / all feeds</td></tr>
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
<tr><td colspan=2>&nbsp;</td></tr>
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
</tr>
</table>
</div>
</modal>
</div>
<!-- external -->
<script src="./static/javascripts/vue.min.js"></script>
<!-- internal -->
<script src="./static/javascripts/api.js"></script>
<script src="./static/javascripts/app.js"></script>
<script src="./static/javascripts/key.js"></script>
</body>
</html>