Channels: Add support for multi-image community posts (#4412)
This PR adds a CSS-only image carousel for community posts with more than one image attached. Closes issue 3522
This commit is contained in:
commit
e0ce59d3e8
119
assets/css/carousel.css
Normal file
119
assets/css/carousel.css
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
/*
|
||||||
|
Copyright (c) 2024 by Jennifer (https://codepen.io/jwjertzoch/pen/JjyGeRy)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person
|
||||||
|
obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without restriction,
|
||||||
|
including without limitation the rights to use, copy, modify,
|
||||||
|
merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall
|
||||||
|
be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||||
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
overflow-x: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slides-item {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 100px;
|
||||||
|
height: 600px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 1rem;
|
||||||
|
position: relative;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform .5s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__nav {
|
||||||
|
padding: 1.25rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-nav {
|
||||||
|
align-items: center;
|
||||||
|
background-color: #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #000;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: .5rem;
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: auto;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
align-items: center;
|
||||||
|
background-color: #000;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
font-size: 30px;
|
||||||
|
height: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: .8;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme .slider-nav {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-theme .slider-nav {
|
||||||
|
background-color: #0005;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.no-theme .slider-nav {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.no-theme .slider-nav {
|
||||||
|
background-color: #0005;
|
||||||
|
}
|
||||||
|
}
|
|
@ -493,5 +493,8 @@
|
||||||
"channel_tab_playlists_label": "Playlists",
|
"channel_tab_playlists_label": "Playlists",
|
||||||
"channel_tab_community_label": "Community",
|
"channel_tab_community_label": "Community",
|
||||||
"channel_tab_channels_label": "Channels",
|
"channel_tab_channels_label": "Channels",
|
||||||
"toggle_theme": "Toggle Theme"
|
"toggle_theme": "Toggle Theme",
|
||||||
|
"carousel_slide": "Slide {{current}} of {{total}}",
|
||||||
|
"carousel_skip": "Skip the Carousel",
|
||||||
|
"carousel_go_to": "Go to slide `x`"
|
||||||
}
|
}
|
||||||
|
|
|
@ -107,6 +107,36 @@ module Invidious::Frontend::Comments
|
||||||
</div>
|
</div>
|
||||||
END_HTML
|
END_HTML
|
||||||
end
|
end
|
||||||
|
when "multiImage"
|
||||||
|
html << <<-END_HTML
|
||||||
|
<section class="carousel">
|
||||||
|
<a class="skip-link" href="#skip-#{child["commentId"]}">#{translate(locale, "carousel_skip")}</a>
|
||||||
|
<div class="slides">
|
||||||
|
END_HTML
|
||||||
|
image_array = attachment["images"].as_a
|
||||||
|
|
||||||
|
image_array.each_index do |i|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<div class="slides-item slide-#{i + 1}" id="#{child["commentId"]}-slide-#{i + 1}" aria-label="#{translate(locale, "carousel_slide", {"current" => (i + 1).to_s, "total" => image_array.size.to_s})}" tabindex="0">
|
||||||
|
<img loading="lazy" src="/ggpht#{URI.parse(image_array[i][1]["url"].as_s).request_target}" alt="" />
|
||||||
|
</div>
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
html << <<-END_HTML
|
||||||
|
</div>
|
||||||
|
<div class="carousel__nav">
|
||||||
|
END_HTML
|
||||||
|
attachment["images"].as_a.each_index do |i|
|
||||||
|
html << <<-END_HTML
|
||||||
|
<a class="slider-nav" href="##{child["commentId"]}-slide-#{i + 1}" aria-label="#{translate(locale, "carousel_go_to", (i + 1).to_s)}" tabindex="-1" aria-hidden="true">#{i + 1}</a>
|
||||||
|
END_HTML
|
||||||
|
end
|
||||||
|
html << <<-END_HTML
|
||||||
|
</div>
|
||||||
|
<div id="skip-#{child["commentId"]}"></div>
|
||||||
|
</section>
|
||||||
|
END_HTML
|
||||||
else nil # Ignore
|
else nil # Ignore
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -78,7 +78,7 @@ def load_all_locales
|
||||||
return locales
|
return locales
|
||||||
end
|
end
|
||||||
|
|
||||||
def translate(locale : String?, key : String, text : String | Nil = nil) : String
|
def translate(locale : String?, key : String, text : String | Hash(String, String) | Nil = nil) : String
|
||||||
# Log a warning if "key" doesn't exist in en-US locale and return
|
# Log a warning if "key" doesn't exist in en-US locale and return
|
||||||
# that key as the text, so this is more or less transparent to the user.
|
# that key as the text, so this is more or less transparent to the user.
|
||||||
if !LOCALES["en-US"].has_key?(key)
|
if !LOCALES["en-US"].has_key?(key)
|
||||||
|
@ -101,6 +101,7 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin
|
||||||
match_length = 0
|
match_length = 0
|
||||||
|
|
||||||
raw_data.as_h.each do |hash_key, value|
|
raw_data.as_h.each do |hash_key, value|
|
||||||
|
if text.is_a?(String)
|
||||||
if md = text.try &.match(/#{hash_key}/)
|
if md = text.try &.match(/#{hash_key}/)
|
||||||
if md[0].size >= match_length
|
if md[0].size >= match_length
|
||||||
translation = value.as_s
|
translation = value.as_s
|
||||||
|
@ -108,14 +109,20 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
when .as_s?
|
when .as_s?
|
||||||
translation = raw_data.as_s
|
translation = raw_data.as_s
|
||||||
else
|
else
|
||||||
raise "Invalid translation \"#{raw_data}\""
|
raise "Invalid translation \"#{raw_data}\""
|
||||||
end
|
end
|
||||||
|
|
||||||
if text
|
if text.is_a?(String)
|
||||||
translation = translation.gsub("`x`", text)
|
translation = translation.gsub("`x`", text)
|
||||||
|
elsif text.is_a?(Hash(String, String))
|
||||||
|
# adds support for multi string interpolation. Based on i18next https://www.i18next.com/translation-function/interpolation#basic
|
||||||
|
text.each_key do |hash_key|
|
||||||
|
translation = translation.gsub("{{#{hash_key}}}", text[hash_key])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return translation
|
return translation
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
|
<link rel="stylesheet" href="/css/grids-responsive-min.css?v=<%= ASSET_COMMIT %>">
|
||||||
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
|
<link rel="stylesheet" href="/css/ionicons.min.css?v=<%= ASSET_COMMIT %>">
|
||||||
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
|
<link rel="stylesheet" href="/css/default.css?v=<%= ASSET_COMMIT %>">
|
||||||
|
<link rel="stylesheet" href="/css/carousel.css?v=<%= ASSET_COMMIT %>">
|
||||||
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
<script src="/js/_helpers.js?v=<%= ASSET_COMMIT %>"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue