Wip Ipfsdocs Slider
Fleetingwip ipfsdocs
root level
<html>
<head>
<meta charset="UTF-8"/>
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" /> -->
<link rel="manifest" href="./manifest.json">
<link rel="icon" href="./icons/icon-512x512.png" type="image/x-icon">
<link rel="shortcut icon" href="./icons/icon-512x512.png" type="image/x-icon">
<script defer src="https://konubinix.eu/ipfs/bafkreib3gcvqpp3fox36m7sn4d6qfcru7n62svzclt5w7lf6j43pkyla4q?orig=https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.js"></script>
<script defer src="https://konubinix.eu/ipfs/bafkreihw6a7byfwe4mtoda5ivd6wkdmclapfij6jul5vdb2on5qmori7fe?orig=https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.js"></script>
<script defer src="https://konubinix.eu/ipfs/bafkreid7munpltjkx2hi3btduhizhajxun6wonz4pkjaemcc2ovl7itw2q?orig=https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.js"></script>
<script src="https://konubinix.eu/ipfs/bafybeihp5kzlgqt56dmy5l4z7kpymfc4kn3fnehrrtr7cid7cn7ra36yha?orig=https://cdn.tailwindcss.com/3.4.3"></script>
<script src="https://konubinix.eu/ipfs/bafkreidbmnazlpto4hcnpdbyicqa3yh3x73e7sknzawgykpzie26zd3hb4?orig=https://cdn.jsdelivr.net/npm/gun/gun.js"></script>
[[toast-code]]
<script>
[[wip_ipfsdocs_with_alpine.org:helpers-ex()]]
</script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => (
[[app-code]]
));
})
</script>
</head>
<body
x-data="app" class="overflow-auto bg-black text-slate-700"
x-init.controller-tools="effect(['autoplay', 'smooth_scroll', 'gun.objects.controller'], () => {
if(gun.objects.controller) {
gun.objects.controller.put({smooth_scroll: smooth_scroll})
gun.objects.controller.put({autoplay: autoplay})
}
})
"
x-effect.avoid-trying-gun-connection-on-night="gun.try_connection = ! night"
x-init.controlled-tools="effect('controlled', () => {
if(controlled != '')
{
var rec_channel = channel
var rec_controlled = controlled
let table = gun.root.get('ipfsdocs').get(channel).get('control').get(controlled)
// when starting to follow, I want to start with the last point. Otherwise, I want to clean it
if(! get_param('follow_controlled'))
{
table.put({smooth_scroll: null, autoplay: null}) // clean the data to avoid being dragged by old interactions
}
table.map().on((value, name, msg, ev) => {
if(rec_channel !== channel || rec_controlled !== controlled){
ev.off()
return
}
if(value !== null)
{
// debug(`setting ${name}: ${value}`)
if(name === 'smooth_scroll'){
smooth_scroll = value
}
if(name === 'autoplay'){
autoplay = value
}
}
})
}
})
"
x-init.subscribe-to-tools="effect('channel', () => {if(channel !== ''){gun.objects.tools = gun.root.get('ipfsdocs').get(channel).get('tools')}})"
x-init.update-tools="effect(['gun.objects.tools'], () => {var recorded_channel = channel; gun.objects.tools.map().on((value, name, msg, ev) => {if(recorded_channel !== channel){ev.off();return} ;
//info(`${name}: ${value}`);
fetch[name] = value})})"
x-init.subscribe-to-docs="effect('channel', () => {if(channel !== ''){alldocs=[];gun.objects.docs = gun.root.get('ipfsdocs').get(channel).get('docs')}})"
x-init.update-docs="effect(
['gun.objects.docs'],
() => {
var rec_channel = channel
gun.objects.docs.map().on(
(data, name, msg, ev) => {
if(rec_channel !== channel){
ev.off();return
}
if(name !== 'docs')
{
return
}
try {
var newData = JSON.parse(data)
info('got new data');
// console.log(`new data ->`)
// console.log(newData)
alldocs = newData
for(hook of docs_update_hooks){
hook()
}
} catch (err)
{
error(`error parsing: ${data}`)
console.log(`error parsing: ${data}`)
throw err
}
})
gun.objects.docs.get('splitdocs').map().on(
(data, name, msg, ev) => {
if(rec_channel !== channel){
ev.off();return
}
console.log(`got data for ${name}`)
})
}
)"
>
<div x-text="message"></div>
[[slider]]
<template x-if="true">
<img id="nightimage"
x-show="night"
x-effect="
if(night_timer){clearTimeout(night_timer)}
if(night_mode){trigger_next_night_time()}else{night = false}
"
x-data="{
candidates: [
'https://i.ytimg.com/vi/_cJbK9_JVK0/maxresdefault.jpg',
'https://ici.exploratv.ca/upload/cms/IMAGES_2021/10-photos-espace-hiver-cosmique.jpg',
'https://img.huffingtonpost.com/asset/5c933c122300004b00ae3b0c.jpeg',
'https://wallpapercave.com/wp/wp3474282.jpg',
'http://www.joliefreebox.com/wp-content/uploads/2013/05/univers-1024x576.jpg',
],
getone(){
return this.candidates[Math.floor(Math.random() * this.candidates.length)];
}}"
class="w-full object-contain min-h-screen" :src="getone"/>
</template>
<div x-data="{message: ''}"
x-show="alldocs.length === 0 && ! night"
x-effect="message = gun.connected ? 'scroll down to configure' : 'initializing'"
class="flex items-center min-w-full h-screen"
>
<span class="text-white text-3xl text-center w-full" x-text="message"></span>
</div>
[[tools]]
</body>
</html>
{
[[gun-js-code]]
[[effect]]
docs_update_hooks: [],
autoplay: Alpine.$persist(false).as("autoplay"),
night_mode: Alpine.$persist(false).as("night_mode"),
night: false,
transition: Alpine.$persist("swipe").as("transition"),
quality: Alpine.$persist("thumbtoweb").as("quality"),
night_timer: null,
trigger_next_night_time() {
const lasthourofday=22
const lastminuteofday=59
const firsthourofday=6
const firstminuteofday=30
now = new Date()
if(
(now.getHours() === lasthourofday && now.getMinutes() >= lastminuteofday)
||
now.getHours() > lasthourofday
||
now.getHours() < firsthourofday
||
(now.getHours() == firsthourofday && now.getMinutes() < firstminuteofday)
)
{
this.night = true
if(now.getHours() <= firsthourofday)
{
milli_until_tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate(), firsthourofday, firstminuteofday, 0, 0) - now
}
else
{
milli_until_tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, firsthourofday, firstminuteofday, 0, 0) - now
}
console.log(`day in ${milli_until_tomorrow} ms`)
this.night_timer = setTimeout(() => {this.trigger_next_night_time()}, milli_until_tomorrow)
}
else
{
this.night = false
const milli_until_tonight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), lasthourofday, lastminuteofday, 0, 0) - now
console.log(`night in ${milli_until_tonight} ms`)
this.night_timer = setTimeout(() => {this.trigger_next_night_time()}, milli_until_tonight)
}
},
autoplay_time: 60*1000,
smooth_scroll: Alpine.$persist(false).as("smooth_scroll"),
controller: Alpine.$persist('').as("controller"),
controlled: get_param('follow_controlled') || Alpine.$persist('').as("controlled"),
doc(index) {
return this.alldocs[((index % this.alldocs.length) + this.alldocs.length) % this.alldocs.length]
},
async init () {
this.gun.init()
await this.get_tags()
},
}
effect
[[effect]]
effect(attr, f) {
f()
if (Array.isArray(attr)) {
attr.forEach((element) => {
this.$watch(element, f);
});
} else {
this.$watch(attr, f);
}
},
toasts
[[toast-code]]
<link rel="stylesheet" type="text/css" href="https://konubinix.eu/ipfs/bafybeienb3tpzdewluwh5i5mzrc7wq3vibqqsp52gf6z4j26winzlsbqlm/toastify.min.css?orig=https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<script type="text/javascript" src="https://konubinix.eu/ipfs/bafybeienb3tpzdewluwh5i5mzrc7wq3vibqqsp52gf6z4j26winzlsbqlm/toastify-js?orig=https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script>
function debug(msg) {
info(msg, 10000)
console.log(msg)
}
function info(msg, time) {
Toastify({
text: JSON.stringify(msg),
duration: time || 3000,
newWindow: true,
close: true,
gravity: "top", // `top` or `bottom`
position: "right", // `left`, `center` or `right`
stopOnFocus: true, // Prevents dismissing of toast on hover
style: {
background: "linear-gradient(to right, #00b09b, #96c93d)",
},
}).showToast();
}
function error(msg) {
Toastify({
text: msg,
duration: -1,
newWindow: true,
close: true,
gravity: "top", // `top` or `bottom`
position: "right", // `left`, `center` or `right`
stopOnFocus: true, // Prevents dismissing of toast on hover
style: {
background: "linear-gradient(to right, orange, red)",
},
}).showToast();
}
</script>
gun/postgrest and docs
fetch_from_min: '2007-01-01',
filter_in: [],
fetch: {
table: 'photovideo',
shuffle: false,
filter_in_raw: '[]',
fromdate: '',
only_like: false,
to: '',
limit: 500,
state: "done",
owner: 'aylapomme',
},
async fetch_docs(_shuffle) {
try{
url = `${server}/postgrest/ipfsdocs_photovideo_view?owner=eq.${this.fetch.owner}&state=eq.${this.fetch.state}&select=cid,thumbnail_cid,web_cid,class,date,metadata,hash&limit=${this.fetch.limit}`
if(this.filter_in.length > 0){
value = JSON.stringify(this.filter_in)
url += `&metadata=cs.${value}`
}
if(this.fetch.only_like){
url += `&metadata=cs.[{"key": "tag", "value": "like"}]`
}
if(this.fetch.fromdate)
{
url += `&date=gte.${this.fetch.fromdate}`
}
if(this.fetch.table !== 'photovideo')
{
url += `&class=eq.${this.fetch.table}`
}
if(this.fetch.to)
{
url += `&date=lte.${this.fetch.to}`
}
if(_shuffle === true || (_shuffle !== false && this.fetch.shuffle))
{
url += "&order=myrandom"
}
else
{
url += "&order=date.asc,web_cid.asc"
}
res = await fetch(url)
_docs = await res.json()
for(var i = 0; i < _docs.length / this.splitsize; i++)
{
let doc = {}
doc[i] = JSON.stringify(_docs.slice(i*this.splitsize, (i+1)*this.splitsize))
this.gun.objects.docs.put({"splitdocs": doc})
}
this.gun.objects.docs.put({"docs": JSON.stringify(_docs)})
l = _docs.length
// info(`fetched from server -> ${l}`)
this.$dispatch('fetched')
} catch (err)
{
error(err)
throw err
}
},
gun: {
init () {
// clean the localstorage to avoid old data to come back
localStorage.removeItem("gun/")
this.root = Gun(this.server)
setInterval(() => {this.check_connection()}, 5000)
},
check_connection() {
if(!this.try_connection)
{
return
}
const peer = this.root.back('opt.peers')[this.server]
if(peer === undefined || peer.wire === undefined || ! peer.wire.readyState)
{
this.connected = false
info("connection lost, reconnecting")
this.root.opt({peers: [this.server]})
this.root.on('out', { get: { '#': this.server } }); // Trigger a get to reconnect
}
else
{
if(this.connected === false){
info("connected")
}
this.connected = true
}
},
server: gunserver,
root: null,
try_connection: true,
connected: false,
objects: {
docs: null,
tools: null,
controller: null,
},
},
channel: get_param('follow_channel') || Alpine.$persist("a").as("channel"),
alldocs: [],
splitsize: 30,
splitdocs: {},
message: "",
metadata: null,
async get_tags() {
try{
res = await fetch(`${server}/postgrest/ipfsdocs_tag_view`)
this.metadata = (await res.json())[0]["values"]
list_keys = Object.keys(this.metadata)
if(! list_keys.includes(this.cur_tag.key)){
this.cur_tag.key = list_keys[0]
}
list_values = this.metadata[this.cur_tag.key]
if(! list_values.includes(this.cur_tag.value)){
this.cur_tag.value = [...list_values].sort()[0]
}
} catch (err)
{
error(err)
throw err
}
},
cur_tag: {
key: Alpine.$persist([]).as("cur_tag_key"),
value: Alpine.$persist([]).as("cur_tag_value"),
},
async get_tag_id(key, value){
try{
keyEncoded = encodeURIComponent(key)
valueEncoded = encodeURIComponent(value)
url = `${server}/postgrest/tag?select=tag_id&value->>key=eq.${keyEncoded}&value->>value=eq.${valueEncoded}&tag_owner=eq.aylapomme`
res = await fetch(url)
res = await res.json()
if(res.length === 0){
throw new Error(`No tag matching ${key}: ${value}, url: ${url}`)
}
tag_id = res[0]["tag_id"]
return tag_id
} catch (err)
{
error(err)
throw err
}
},
async apply_tag(cids, key, value) {
try{
tag_id = await this.get_tag_id(key, value)
data = []
for(cid of cids)
{
data.push({cid: cid, tag_id: tag_id})
}
// first, remove, to avoid issues with conflicts
await this.remove_tag(cids, key, value)
res = await fetch(
`${server}/postgrest/tagmap`,
{
headers: {
"Content-Type": "application/json",
},
"method": "POST",
"body": JSON.stringify(data)
}
)
} catch (err)
{
error(err)
throw err
}
},
async remove_tag(cids, key, value) {
try{
tag_id = await this.get_tag_id(key, value)
const selection_list = cids.join(",")
url = `${server}/postgrest/tagmap?cid=in.(${selection_list})&tag_id=eq.${tag_id}`
res = await fetch(url,
{
"method": "DELETE",
}
)
} catch (err)
{
error(err)
throw err
}
},
async move_to_state(cids, state) {
const cid_list = cids.join(",")
const url = `${server}/postgrest/photovideo?cid=in.(${cid_list})`
try{
res = await fetch(
url,
{
method: 'PATCH',
body: JSON.stringify({state: state}),
headers: {
'Content-Type': 'application/json'
},
}
)
} catch (err)
{
error(err)
throw err
}
},
[[gun-js-code]]
modal
<template x-if="modal.show">
<div
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-white text-black bg-opacity-10"
@click.stop="modal.show = false"
@keydown.window.escape="modal.show = false"
x-transition:enter="motion-safe:ease-out duration-300"
x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100"
>
<template x-if="metadata">
<div @click.stop="">
<select x-effect="await $nextTick() ; $el.value = cur_tag.key"
@change="
cur_tag.key = $el.value
cur_tag.value = [...metadata[$el.value]].sort()[0]
">
<template x-for="key in [...Object.keys(metadata)].sort()">
<option :value="key" x-text="key"></option>
</template>
</select>
<select x-effect="await $nextTick() ; $el.value = cur_tag.value"
@change="cur_tag.value = $el.value">
<template x-for="value in [...metadata[cur_tag.key]].sort()">
<option :value="value" x-text="value"></option>
</template>
</select>
<div>
<button class="m-2 rounded bg-white" @click="wanted_index = normalized_index ; await apply_tag([current_doc.cid], cur_tag.key, cur_tag.value) ; modal.show = false ; await fetch_docs() ; info(`added ${cur_tag.key}: ${cur_tag.value}`)">apply</button>
<button class="m-2 rounded bg-white" @click="wanted_index = normalized_index ; let key = cur_tag.key ; let value = cur_tag.value ; await remove_tag([current_doc.cid], key, value) ; modal.show = false ; await fetch_docs() ; info(`removed ${key}: ${value}`)">remove</button>
</div>
<div>
<div>
<select x-ref="wanted_state" x-model="modal.wanted_state">
<option value="todo">todo</option>
<option value="next">next</option>
<option value="done">done</option>
<option value="delete">delete</option>
</select>
<div>
<button class="m-2 rounded bg-white" @click="wanted_index = normalized_index ; await move_to_state([current_doc.cid], modal.wanted_state) ; modal.show = false ; await fetch_docs() ; info(`applied -> ${modal.wanted_state}`)">move</button>
</div>
</div>
</div>
</div>
</template>
<div>
<img :src="current_doc && current_doc.thumbnail_cid ? 'https://konubinix.eu' + current_doc.thumbnail_cid : ''"/>
</div>
<div
x-data="{url: ''}"
>
<a class="bg-white" target="_blank" :href="share_url">share</a>
</div>
</div>
</template>
slider
{
// span conveys how many items to show on the left and on the
// right of the cursor
span: 5,
number: 50,
page: 0, // Alpine.$persist(0).as("page"),
cursor: 0, // Alpine.$persist(0).as("cursor"),
cur_cid: Alpine.$persist(null).as("cur_cid"),
scroll: null,
begScroll: null,
endScroll: null,
autoplay_timer: null,
current_doc: null,
modal: {
show: false,
wanted_state: Alpine.$persist('done').as("modal_wanted_state"),
},
update_current_doc(){
this.current_doc = this.docs_to_show[this.cursor]
},
resetsize_timer: null,
async init() {
// window.visualViewport.onresize = (event) => {
// if(this.resetsize_timer){
// clearTimeout(this.resetsize_timer)
// this.resetsize_timer = null
// }
// if(this.zoomed_in) {
// this.resetsize_timer = setTimeout(() => {
// info('clear')
// reset_zoom()
// this.resetsize_timer = null
// }, 1)
// }
// else{
// info(`${this.zoomed_in} / ${window.visualViewport.width} / ${document.body.clientWidth}`)
// }
// };
this.docs_to_show = this._docs_to_show()
this.update_current_doc()
},
get zoomed_in() {
return (window.visualViewport.width < 0.95 * document.body.clientWidth)
},
async on_scroll () {
endmark = this.$refs.endmark
begmark = this.$refs.begmark
this.scroll = this.$refs.cont.scrollLeft
this.endScroll = endmark.offsetLeft - (this.$refs.cont.offsetLeft + this.$refs.cont.offsetWidth);
this.begScroll = begmark.offsetLeft
let value = null
let threshold = 1
if(this.scroll <= this.begScroll - threshold){
await this.prev_page()
}
else if(this.scroll >= this.endScroll + threshold){
await this.next_page()
}
this.scroll = this.$refs.cont.scrollLeft
this.update_cursor()
},
wanted_index: null,
get normalized_index(){
let index = this.page * this.number + this.cursor
let len = this.alldocs.length
index = ((index % len) + len ) % len
return index
},
update_cursor () {
// 2 placeholders + the number of docs + the extra doc to
// perform the loop
pieceOfWidth = this.$refs.cont.scrollWidth / (this.number + 3)
// -1 because the first one is a placholder. the first doc
// starts at position 1
result = Math.floor(((this.scroll + pieceOfWidth / 2) / pieceOfWidth) - 1)
this.cursor = result
},
docs_to_show: {},
_docs_to_show() {
result = {}
// for(var i = 0 ; i <= this.number ; i++){
// result[i] = this.doc(i)
// }
// return result
for(var i = this.cursor - this.span; i <= this.cursor + this.span; i++) {
normalized_index = (i + this.number) % this.number
result[normalized_index] = this.doc(i + this.page * this.number)
if(i === this.number) // last placeholder, should contain an image that ensures continuity
{
// if I'm close to the beginning of the page, the last
// placeholder should contain the one on the left of the page,
// hence the last of the previous page
if(this.cursor < this.number / 2)
{
result[i] = this.doc(this.page * this.number - 1)
}
// if I'm close to the end of the range, the last placeholder
// should contain the one on the right of the page, hence the
// first of next page
else
{
result[i] = this.doc((this.page + 1) * this.number)
}
}
}
return result
},
show_click: false,
click_number: 0,
click_timer: null,
click_show_timer: null,
async clicked(event) {
this.show_click = true
if(this.click_show_timer)
{
clearTimeout(this.click_show_timer)
}
this.click_show_timer = setTimeout(() => {this.show_click = false}, 100)
this.click_number += 1
let click_time = 200
if(this.click_timer){
clearTimeout(this.click_timer)
}
if(this.click_number === 1)
{
this.click_timer = setTimeout(async () => {await this.deal_with_click(event) ; this.click_number = 0}, click_time)
}
else if(this.click_number === 2)
{
this.click_timer = setTimeout(async () => {info('double click') ; this.click_number = 0}, click_time * 1.5)
}
else if(this.click_number === 3)
{
this.click_timer = setTimeout(async () => {await this.deal_with_triple_click(event) ; this.click_number = 0}, click_time)
}
else{
info(`${this.click_number} clicks in a row!`)
this.click_timer = setTimeout(async () => {this.click_number = 0}, click_time)
}
},
async deal_with_triple_click(event) {
this.$dispatch('togglelike', {doc: this.current_doc})
},
async deal_with_click(event) {
this.show_click = true
await this.$nextTick() // for the show_click to take effect before the heavy computation
setTimeout(() => {this.show_click = false}, 100)
if(!this.zoomed_in){
pos = event.offsetX / event.target.clientWidth
if(pos < 0.25) {
await this.move_to_prev()
}
else if (pos > 0.75) {
await this.move_to_next()
}
else {
this.modal.show = true
}
}
},
freeze: false,
touching: false,
scrolling_to: null,
async move_to_next(immediate) {
if(this.cursor === this.number) {
await this.next_page()
// wait for the page change to be reflected in the DOM
await this.$nextTick()
}
this.$dispatch('scrollintoview', {pos: this.cursor + 1, immediate: immediate})
},
async move_to_prev(immediate) {
if(this.cursor === 0)
{
await this.prev_page()
// wait for the page change to be reflected in the DOM
await this.$nextTick()
}
this.$dispatch('scrollintoview', {pos: this.cursor - 1, immediate: immediate})
},
swiping: false,
async prev_page() {
this.$refs.cont.scrollLeft = this.endScroll - Math.max(0, this.begScroll - this.scroll)
this.page = this.page - 1
// info(`scrolling to ${this.$refs.cont.scrollLeft}`)
},
async next_page() {
this.$refs.cont.scrollLeft = this.begScroll + Math.max(0, this.scroll - this.endScroll)
this.page = this.page + 1
},
async scroll_to_cur_cid_or_wanted_index() {
let index = this.wanted_index
if(index === null && this.cur_cid){
index = this.alldocs.findIndex((doc) => doc.cid === this.cur_cid)
if(index === -1){
index = null
console.log(`cur: ${this.cur_cid}, index:${index}: not found in doc of size ${this.alldocs.length}`)
console.log(this.alldocs)
// wait for the slider to be init
await this.$nextTick()
this.$dispatch('scrollintoview', {pos: 0, immediate: true})
}
}
if(index !== null)
{
this.page = Math.floor(index / this.number)
let cursor = Math.floor(index % this.number)
// wait for the slider to be init
await this.$nextTick()
// then scroll the current cursor
this.$dispatch('scrollintoview', {pos: cursor, immediate: true})
await this.$nextTick()
// info(`cid: ${cur_cid}, index: ${index}, page: ${page}, cursor: ${cursor}`)
}
else
{
// wait for the slider to be init
await this.$nextTick()
this.$dispatch('scrollintoview', {pos: 0, immediate: true})
}
this.wanted_index = null
this.$dispatch('initialscrolldone')
},
get share_url() {
if(! this.current_doc) {return}
let ext
if(this.current_doc.class === 'video'){
ext = 'mp4'
}
else
{
ext = 'jpg'
}
// return 'https://konubinix.eu/braindump/posts/simple_image_video_sharer/?open=t&url=' + this.current_doc.web_cid + '?a.' + ext
return 'https://konubinix.eu/braindump/posts/3dbc869d-4264-4524-8970-5884d406aa8d/?title=simple_image_video_sharer&open=t&url=' + this.current_doc.web_cid + '?a.' + ext
},
}
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('slider', () => (
[[slider-code]]
));
})
</script>
<div x-show="alldocs.length > 0 && !night"
@togglelike="
if($event.detail.doc.metadata.findIndex((el) => el.key === 'tag' && el.value === 'like') === -1)
{
await apply_tag([$event.detail.doc.cid], 'tag', 'like')
info(`added 💜`)
}
else
{
await remove_tag([$event.detail.doc.cid], 'tag', 'like')
info(`removed 💜`)
}
await fetch_docs()"
>
<div
class="flex overflow-x-auto w-full touch-manipulation"
:class="{
'bg-slate-700': show_click,
'bg-black': !show_click && gun.connected,
'bg-pink-900': !show_click && !gun.connected,
'bg-yellow-700': touching && transition === 'swipe',
'bg-orange-700': swiping && transition === 'swipe',
// 'scroll-smooth': smooth_scroll, idea to dig into, but I might want more precise control
}"
x-data="slider"
id="slider"
x-effect.update-current-doc="update_current_doc()"
@reset.window="page = 0 ; $dispatch('scrollintoview', {pos: 0, immediate: true})"
x-effect.update-docs-to-show="docs_to_show = _docs_to_show()"
x-effect.publish-progress="
$dispatch('progress',
{
cur:(page * number + cursor),
cursor: cursor,
page: page,
number: number,
total: alldocs.length
}
)
"
x-effect.cur-cid-follows-current-doc="if(current_doc){cur_cid = current_doc.cid}
// console.log(`cur_cid: ${cur_cid}, current_doc: ${current_doc}`)
"
x-init.register-init-scroll-to-docs-update="docs_update_hooks.push(async () => {await scroll_to_cur_cid_or_wanted_index()})"
x-init.controlled-setup="effect('controlled', () => {
if(controlled !== ''){
table = gun.root.get('ipfsdocs').get(channel).get('control').get(controlled)
if(! get_param('follow_controlled'))
{
table.put({page: null, cursor: null, cid: null, share_url: null}) // clean the data to avoid being dragged by old interactions
}
recorder_controlled = controlled
recorded_channel = channel
table.map().on((value, name, msg, ev) =>
{
if(recorded_channel !== channel || recorder_controlled !== controlled)
{
ev.off()
return
}
if(value !== null)
{
// debug(`setting ${name}: ${value}`)
if(name === 'page')
{
page = value
}
if(name === 'cursor')
{
$dispatch('scrollintoview', {pos: value})
}
}
}
)
}
})
"
x-init.push-control="effect(['page', 'cursor', 'gun.objects.controller'], () => {
if(current_doc && gun.objects.controller !== null) {
control = {page: page, cursor: cursor, cid: current_doc.cid, share_url: share_url}
gun.objects.controller.put(control)
}
})
"
x-init.setup-controller="effect('controller', () => {gun.objects.controller = null; if(controller !== ''){gun.objects.controller = gun.root.get('ipfsdocs').get(channel).get('control').get(controller)}})"
@freeze="freeze = $event.detail.freeze"
@touchstart="touching = {t: Date.now(), x1: $event.touches[0].clientX}; swiping = true"
@touchmove="touching['x2'] = $event.touches[0].clientX
if($event.touches.length > 1 || transition !== 'swipe' || zoomed_in)
{
swiping = false
}
if(swiping)
{
$event.preventDefault()
}
"
@touchend="
let duration = Date.now() - touching['t']
let distance = touching['x2'] - touching['x1']
let speed = distance / duration // negative means right
if(swiping){
show_click = true
await $nextTick() // for the show_click to take effect before the heavy computation
setTimeout(() => {show_click = false}, 100)
if(speed > 0)
{
await move_to_prev()
}
else if(speed < -0)
{
await move_to_next()
}
}
touching = false
swiping = false
"
@move_to_next="move_to_next"
@move_to_prev="move_to_prev"
x-effect.setup-autoplay="if(autoplay_timer){clearInterval(autoplay_timer) ; autoplay_timer = null} ; if(autoplay && ! freeze && ! modal.show && ! touching){autoplay_timer = setInterval(async () => {await move_to_next()}, autoplay_time)}"
x-ref="cont"
x-effect.load-better-quality-when-toggling-on-to-option="
if(quality !== 'thumb'){
$dispatch('loadwebquality', {pos: cursor})
}
"
@click="await clicked($event)"
@keydown.window.delete="modal.show = true"
@keydown.window.d="if(confirm('delete?')){wanted_index = normalized_index ; await move_to_state([current_doc.cid], 'delete') ; await fetch_docs() ; info('deleted')}"
@keydown.window.a="if($event.key === 'A'){return} ; if(confirm(`apply ${modal.wanted_state}?`)){wanted_index = normalized_index ; await move_to_state([current_doc.cid], modal.wanted_state) ; await fetch_docs() ; info('applied')}"
@keydown.window.shift.a="if(confirm(`apply ${cur_tag.key}: ${cur_tag.value}?`)){wanted_index = normalized_index ; let key = cur_tag.key ; let value = cur_tag.value ; await apply_tag([current_doc.cid], key, value) ; modal.show = false ; await fetch_docs() ; info(`added ${key}: ${value}`)}"
@keydown.window.arrow-right="await move_to_next()"
@keydown.window.arrow-left="await move_to_prev()"
@scroll.throttle.50ms="on_scroll"
@scroll.debounce.500ms="// I have no certainty that the debounced event above will trigger the last scroll event, so i need to at least process this last event to compute accurately the cursor
await on_scroll()
if(scrolling_to === cursor || touching || zoomed_in || (transition !== 'scroll_snap')){return}
$dispatch('scrollintoview', {pos: cursor})"
>
[[modal]]
<!-- placeholder to avoid stopping the scroll animation -->
<div class="text-white bg-slate-900 min-w-full">
<img class="w-full h-screen object-contain" :src="docs_to_show[number-1] ? 'https://konubinix.eu' + docs_to_show[number-1].thumbnail_cid : ''"/>
</div>
<span x-ref="begmark"></span>
<template x-show="!night" x-for="i in number + 1">
[[doc]]
</template>
<!-- placeholder to avoid stopping the scroll animation -->
<div x-ref="endmark"></div>
<div class="text-white bg-slate-900 min-w-full">
<img class="w-full h-screen object-contain" :src="docs_to_show[1] ? 'https://konubinix.eu' + docs_to_show[1].thumbnail_cid : ''"/>
</div>
</div>
<input min="0" @progress.window="$el.value = (($event.detail.cur % $event.detail.total) + $event.detail.total) % $event.detail.total ; $el.max = $event.detail.total" type="range" class="w-full h-3 bg-red-500" disabled="true"/>
</div>
doc
<div x-data="{
get mycursor() {
return i - 1
},
get mydoc() {
res = this.docs_to_show[this.mycursor]
if(res && res.metadata === null){
res.metadata = []
}
return res
},
get mymetadata() {
var doc = this.mydoc
if(doc)
{
return doc.metadata
}
return []
},
get iscurrent() {
return this.mycursor === this.cursor
},
async move_to_prev() {
this.$dispatch('move_to_prev')
},
async move_to_next() {
this.$dispatch('move_to_next')
},
}
"
class="min-w-full bg-black-500"
>
<div
class="relative border-3 border-red-500 w-full"
@scrollintoview.window="if($event.detail.pos === mycursor){
// $el.scrollIntoView({behavior: smooth_scroll && ! $event.detail.immediate ? 'smooth' : 'instant'})
let slider = document.querySelector('#slider')
if(smooth_scroll && ! $event.detail.immediate)
{
slider.scroll({left: $el.offsetLeft, behavior: 'smooth'})
}
else
{
slider.scrollLeft = $el.offsetLeft
}
document.body.scroll({top: 0, behavior: smooth_scroll && ! $event.detail.immediate ? 'smooth' : 'instant'})
scrolling_to = mycursor
$dispatch('scrolledintoview', {pos: $event.detail.pos})
await $nextTick() // waiting to make sure the underlying image is loaded
$dispatch('loadwebquality', {pos: $event.detail.pos})
}"
>
<div x-show="mydoc && mydoc.metadata.findIndex((el) => el.key === 'tag' && el.value === 'like') !== -1"
class="absolute bottom-1 right-1 z-10">
💜
</div>
<template x-if="mydoc && (mydoc.class === 'photo')">
<img
x-data="{loaded: false, inerror: false, load_timer: null, webquality: (quality === 'web')}"
class="w-full h-screen object-contain"
:class="{'border-4':!loaded, 'border-blue-900': !inerror && !loaded, 'border-red-500': error}"
@load="loaded = true
if(load_timer){
clearTimeout(load_timer)
load_timer = null
}
"
@error="inerror = true"
@keydown.window.n="if(iscurrent){await move_to_next()}"
@keydown.window.p="if(iscurrent){await move_to_prev()}"
x-init.timeout-load-on-src-change="effect('mydoc', () => {
if(load_timer){
clearTimeout(load_timer)
load_timer = null
}
load_timer = setTimeout(() => {inerror = true}, 10000)
})"
:src="mydoc ? 'https://konubinix.eu' + (webquality ? mydoc.web_cid : mydoc.thumbnail_cid) : 'https://imgs.search.brave.com/dFxiYCEW58PS8jxeHTWTmQyMDG4_APgTUviyG0TQe1k/rs:fit:500:0:0/g:ce/aHR0cHM6Ly9tZWRp/YTEuZ2lwaHkuY29t/L21lZGlhL3YxLlky/bGtQVGM1TUdJM05q/RXhkWEZsZHpacGVu/QjJObW8wT1hKMGRX/NDJhM2hsYUdVNE56/aHlkWEZzTW1GMmNX/TnhibTFzYUNabGNE/MTJNVjluYVdaelgz/TmxZWEpqYUNaamRE/MW4vM29Fakk2U0lJ/SEJkUnhYSTQwL2dp/cGh5LmdpZg.jpeg'"
@loadwebquality.window="if($event.detail.pos === mycursor){
if(quality === 'thumbtoweb' && ! webquality){
loaded = false
webquality = true
}
}"
/>
<link rel="prefetch" :href="'https://konubinix.eu' + mydoc.web_cid"></link>
</template>
<template x-if="mydoc && (mydoc.class === 'video')">
<div class="relative w-full h-screen" x-data="{playing: false, id: $id('video'), ended: false}">
<video
x-data="
{
toggle() { this.$el.paused ? this.$el.play() : this.$el.pause() }
}
"
controls="controls"
:id="id"
x-effect="ended = false"
x-effect.inform-that-playing="if(mydoc === current_doc){$dispatch('freeze', {freeze: playing})}"
@pause="playing = false"
@ended="playing = false ; ended = true"
@play="playing = true"
@keydown.window.t="if(mydoc === current_doc){toggle()}"
@keydown.window.space.prevent="if(mydoc === current_doc){toggle()}"
@keydown.window.n="if(iscurrent){if(playing){$el.currentTime += 5}else if(ended){await move_to_next()}else{toggle()}}"
@keydown.window.p="if(iscurrent){if(playing){$el.currentTime -= 5}else{await move_to_prev()}}"
@toggle.window="if($event.detail.web_cid === (mydoc.web_cid)){toggle()}"
x-intersect:leave.threshold.70="$el.pause()"
class="w-full h-full object-contain"
preload="none"
:poster="mydoc ? 'https://konubinix.eu' + mydoc.thumbnail_cid : ''"
x-effect.make-sure-video-is-loaded="if(mydoc){$el.load()}"
>
<source :src="mydoc ? 'https://konubinix.eu' + mydoc.web_cid : ''" type="video/mp4">
</video>
<div
class="absolute inset-0 flex items-center justify-center"
x-show="(mydoc && mydoc.web_cid) === (current_doc && current_doc.web_cid) && ! playing"
>
<img
class="w-1/6"
@click.stop="document.getElementById(id).play()" src="http://www.clipartbest.com/cliparts/9TR/R9b/9TRR9bj8c.png"
/>
</div>
</div>
</template>
</div>
<div class="w-full text-center bg-slate-400" @click="$dispatch('scrollintoview', {pos: cursor})">
<div x-text="current_doc && current_doc.date"></div>
</div>
<div class="w-full">
<template x-for="metadata in mymetadata">
<div class="w-full bg-slate-400" x-text="metadata['key'] + ': ' + metadata['value']"></div>
</template>
</div>
<template x-if="true">
<div>
hash: <span x-text="mydoc && mydoc.hash"></span>
page: <span x-text="page"></span>,
mycursor: <span x-text="mycursor"></span>,
docs: <span x-text="page * number + cursor"></span> / <span x-text="alldocs.length"></span>
</div>
</template>
</div>
tools
<div>
<div x-data="{show_control: false}">
<div>
<span class="bg-slate-100">
smooth
<input class="h-10 w-10" x-model="smooth_scroll" type="checkbox">
autoplay
<input class="h-10 w-10" x-model="autoplay" type="checkbox">
night mode
<input class="h-10 w-10" x-model="night_mode" type="checkbox">
transition
<select x-model="transition"
>
<option value="scroll_snap">scroll snap</option>
<option value="scroll_no_snap">scroll no snap</option>
<option value="swipe">swipe</option>
</select>
quality
<select x-model="quality"
>
<option value="web">web</option>
<option value="thumbtoweb">thumb to web</option>
<option value="thumb">thumb</option>
</select>
</span>
<button class="m-2 bg-slate-300 rounded" @click="$dispatch('reset')">
reset
</button>
</div>
<div class="m-2">
<span class="bg-slate-100">
channel
<input
x-data="{updating: false}"
:class="{'bg-green-500': updating}"
@input="updating = true"
@input.debounce.2000ms="updating = false"
x-model.debounce.2000ms="channel"/>
</span>
</div>
<div>
<span class=bg-slate-100>
control
<input class="h-10 w-10" x-model="show_control" type="checkbox"/>
</span>
</div>
<div x-show="show_control" class="m-2">
<span class="bg-slate-100">
controller
<input class="m-2"
x-data="{updating: false}"
:class="{'bg-green-500': updating}"
@input="updating = true"
@input.debounce.2000ms="updating = false"
x-model.debounce.2000ms="controller"/>
controlled
<input class="m-2"
x-data="{updating: false}"
:class="{'bg-green-500': updating}"
@input="updating = true"
@input.debounce.2000ms="updating = false"
x-model.debounce.2000ms="controlled"/>
</span>
</div>
</div>
<div class="bg-slate-100 h-1 rounded-full m-2"></div>
<div
x-data="{show_metadata: false}"
x-effect="show_metadata = show_metadata || filter_in.length > 0"
>
<button class="m-2 bg-slate-300 rounded" @click="info('fetching') ; await fetch_docs() ; info('fetched')">
fetch
</button>
<div class="m-2">
<input x-data="{
adjust_date() {
var givenDate = new Date($el.value)
var minDate = new Date($el.min)
if(givenDate <= minDate) {
$el.value = minDate.toISOString().split('T')[0]
}
},
}
"
:value="fetch.fromdate"
:min="fetch_from_min"
:max="fetch.to ? fetch.to : ''"
type="date"
@input="adjust_date() ; gun.objects.tools.put({fromdate: $el.value})"
/>
<input :value="fetch.to"
@input="gun.objects.tools.put({to: $el.value})"
:min="fetch.fromdate ? fetch.fromdate : ''"
type="date"
/>
<button class="m-2 bg-slate-300 rounded"
@click="
let fromdate = random_date(new Date(fetch_from_min), new Date(Date.now())).toISOString().split('T')[0]
gun.objects.tools.put({fromdate: fromdate})
gun.objects.tools.put({to: random_date(new Date(fromdate), new Date(Date.now())).toISOString().split('T')[0]})
">
random
</button>
<button class="m-2 bg-slate-300 rounded"
@click="
gun.objects.tools.put({fromdate: ''})
gun.objects.tools.put({to: ''})
">
clear
</button>
</div>
<div class="m-2">
<input type="number" class="max-w-20"
:value="fetch.limit"
max=1000
@input.debounce.500ms="let max = Math.floor($el.max) ; if($el.value > max){alert(`limiting the limit to at most ${max} due to some technical limitation`); $el.value = max} ; gun.objects.tools.put({limit: $el.value})"
/>
<select :value="fetch.state" @change="gun.objects.tools.put({state: $el.value})">
<option value="todo">todo</option>
<option value="next">next</option>
<option value="done">done</option>
<option value="delete">delete</option>
</select>
<select :value="fetch.owner" @change="gun.objects.tools.put({owner: $el.value})">
<option value="aylapomme">aylapomme</option>
<option value="konubinix">konubinix</option>
</select>
</div>
<div>
<span class=bg-slate-100>
metadata
<input class="h-10 w-10" x-model="show_metadata" type="checkbox"/>
💜
<input class="h-10 w-10" :value="fetch.only_like" @input="gun.objects.tools.put({only_like: $el.checked})" type="checkbox"/>
🎥
<select :value="fetch.table" @change="gun.objects.tools.put({table: $el.value})">
<option value="photo">photo only</option>
<option value="video">video only</option>
<option value="photovideo">all</option>
</select>
</span>
</div>
<template x-if="metadata">
<div class="m-2"
x-show="show_metadata"
x-data="{key: Object.keys(metadata)[0], value: [...metadata[Object.keys(metadata)[0]]].sort()[0]}">
<select x-model="key">
<template x-for="key in [...Object.keys(metadata)].sort()">
<option :value="key" x-text="key"></option>
</template>
</select>
<select x-model="value">
<template x-for="value in [...metadata[key]].sort()">
<option :value="value" x-text="value"></option>
</template>
</select>
<button class="bg-slate-300 rounded" @click="gun.objects.tools.put({filter_in_raw: JSON.stringify([])})">clean</button>
<button class="bg-slate-300 rounded" @click="gun.objects.tools.put({filter_in_raw: JSON.stringify(filter_in.concat([{key: key, value: value}]))})">add</button>
<div class="flex"
x-effect="filter_in = JSON.parse(fetch.filter_in_raw)"
>
<template x-for="filter in filter_in">
<span class="bg-green-300 rounded-full"><span x-text="filter['key']"></span>: <span x-text="filter['value']"></span></span>
</template>
</div>
</div>
</template>
<div>
<span class="bg-slate-100 m-2">
shuffle
<input class="h-10 w-10"
:value="fetch.shuffle"
@input="gun.objects.tools.put({shuffle: $el.checked})"
type="checkbox">
</span>
</div>
</div>
<div>
<a :href="`${server}/stack-index/posts/wip_ipfsdocs_lots_of_thumbs_with_img/?open=t`">thumbs</a>
</div>
</div>
manifest
{
"name": "ipfsdoc",
"short_name": "ipfsdoc",
"description": "ipfsdoc",
"start_url": "./index.html",
"display": "fullscreen",
"background_color": "#ffffff",
"theme_color": "#4285f4",
"icons": [
{
"src": "./icons/icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "./icons/icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"orientation": "any",
"prefer_related_applications": false,
"scope": "./",
"permissions": [],
"splash_pages": null,
"categories": []
}
icons
ipfs get -o https://konubinix.eu/ipfs/bafybeibuk3dkn6qwbo6cpnmgh26zupcl2ytm4gggu6nxgqoo4xoctuoy4i -o /home/sam/perso/perso/test/icons
the request
create or replace view ipfsdocs_photovideo_view as SELECT state,
filename,
mimetype,
class,
cid,
web_cid,
thumbnail_cid,
date,
metadata,
owner,
myrandom,
hash
FROM ( SELECT files.res_state AS state,
files.res_filename AS filename,
files.res_mimetype AS mimetype,
files.res_class AS class,
files.cid,
files.res_web_cid AS web_cid,
files.res_thumbnail_cid AS thumbnail_cid,
files.res_date AS date,
files.owner,
COALESCE(json_agg(tag.value) FILTER (WHERE tag.value IS NOT NULL), NULL::json)::jsonb AS metadata,
files.res_myrandom AS myrandom, files.perceptualhash as hash
FROM ( SELECT f.state AS res_state,
concat(to_char(f.date, 'YYMMDD_HHMMSS'::text), '-', "right"(f.cid::text, 3)) AS res_filename,
f.mimetype AS res_mimetype,
f.tableoid::regclass::text AS res_class,
f.cid,
f.web_cid AS res_web_cid,
f.thumbnail_cid AS res_thumbnail_cid,
f.date AS res_date,
f.owner,
f.myrandom AS res_myrandom, f.perceptualhash
FROM photovideo f
LEFT JOIN tagmap USING (cid)
LEFT JOIN tag t USING (tag_id)
GROUP BY f.state, (concat(to_char(f.date, 'YYMMDD_HHMMSS'::text), '-', "right"(f.cid::text, 3))), f.mimetype, (f.tableoid::regclass), f.cid, f.web_cid, f.thumbnail_cid, f.date) files
LEFT JOIN ( SELECT tagmap.tag_id,
tagmap.cid,
tag_1.tag_owner,
tag_1.value
FROM tagmap
LEFT JOIN tag tag_1 USING (tag_id)) tag USING (cid)
WHERE tag.tag_owner = files.owner OR tag.tag_owner IS NULL
GROUP BY files.res_state, files.res_filename, files.res_mimetype, files.res_class, files.cid, files.res_myrandom, files.res_web_cid, files.res_thumbnail_cid, files.res_date, files.owner, files.perceptualhash
ORDER BY files.res_date) unnamed_subquery;
CREATE VIEW
\d+ ipfsdocs_photovideo_view
View "public.ipfsdocs_photovideo_view"
Column Type Collation Nullable Default Storage Description
state state plain
filename text extended
mimetype character varying(100) extended
class text extended
cid character varying(255) extended
web_cid character varying(255) extended
thumbnail_cid character varying(255) extended
date timestamp with time zone plain
metadata jsonb extended
owner owner_type plain
myrandom double precision plain
View definition:
SELECT state,
filename,
mimetype,
class,
cid,
web_cid,
thumbnail_cid,
date,
metadata,
owner,
myrandom
FROM ( SELECT files.res_state AS state,
files.res_filename AS filename,
files.res_mimetype AS mimetype,
files.res_class AS class,
files.cid,
files.res_web_cid AS web_cid,
files.res_thumbnail_cid AS thumbnail_cid,
files.res_date AS date,
files.owner,
COALESCE(json_agg(tag.value) FILTER (WHERE tag.value IS NOT NULL), NULL::json)::jsonb AS metadata,
files.res_myrandom AS myrandom
FROM ( SELECT f.state AS res_state,
concat(to_char(f.date, 'YYMMDD_HHMMSS'::text), '-', "right"(f.cid::text, 3)) AS res_filename,
f.mimetype AS res_mimetype,
f.tableoid::regclass::text AS res_class,
f.cid,
f.web_cid AS res_web_cid,
f.thumbnail_cid AS res_thumbnail_cid,
f.date AS res_date,
f.owner,
f.myrandom AS res_myrandom
FROM photovideo f
LEFT JOIN tagmap USING (cid)
LEFT JOIN tag t USING (tag_id)
GROUP BY f.state, (concat(to_char(f.date, 'YYMMDD_HHMMSS'::text), '-', "right"(f.cid::text, 3))), f.mimetype, (f.tableoid::regclass), f.cid, f.web_cid, f.thumbnail_cid, f.date) files
LEFT JOIN ( SELECT tagmap.tag_id,
tagmap.cid,
tag_1.tag_owner,
tag_1.value
FROM tagmap
LEFT JOIN tag tag_1 USING (tag_id)) tag USING (cid)
WHERE tag.tag_owner = files.owner OR tag.tag_owner IS NULL
GROUP BY files.res_state, files.res_filename, files.res_mimetype, files.res_class, files.cid, files.res_myrandom, files.res_web_cid, files.res_thumbnail_cid, files.res_date, files.owner
ORDER BY files.res_date) unnamed_subquery;