Konubinix' opinionated web of thoughts

Wip Ipfsdocs Slider

Fleeting

wip 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;