Created
December 14, 2017 23:14
-
-
Save The0x539/82e034468c4489edf37b28bd4f4849b9 to your computer and use it in GitHub Desktop.
CADDY BROWSE VIDEO THINGY
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright 2015 Light Code Labs, LLC | |
// | |
// Licensed under the Apache License, Version 2.0 (the "License"); | |
// you may not use this file except in compliance with the License. | |
// You may obtain a copy of the License at | |
// | |
// http://www.apache.org/licenses/LICENSE-2.0 | |
// | |
// Unless required by applicable law or agreed to in writing, software | |
// distributed under the License is distributed on an "AS IS" BASIS, | |
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
// See the License for the specific language governing permissions and | |
// limitations under the License. | |
// Package browse provides middleware for listing files in a directory | |
// when directory path is requested instead of a specific file. | |
package browse | |
import ( | |
"bytes" | |
"encoding/json" | |
"net/http" | |
"net/url" | |
"os" | |
"path" | |
"regexp" | |
"sort" | |
"strings" | |
"text/template" | |
"time" | |
"github.com/dustin/go-humanize" | |
"github.com/mholt/caddy/caddyhttp/httpserver" | |
"github.com/mholt/caddy/caddyhttp/staticfiles" | |
) | |
const ( | |
sortByName = "name" | |
sortByNameDirFirst = "namedirfirst" | |
sortBySize = "size" | |
sortByTime = "time" | |
) | |
// Browse is an http.Handler that can show a file listing when | |
// directories in the given paths are specified. | |
type Browse struct { | |
Next httpserver.Handler | |
Configs []Config | |
IgnoreIndexes bool | |
} | |
// Config is a configuration for browsing in a particular path. | |
type Config struct { | |
PathScope string // the base path the URL must match to enable browsing | |
Fs staticfiles.FileServer | |
Variables interface{} | |
Template *template.Template | |
} | |
// A Listing is the context used to fill out a template. | |
type Listing struct { | |
// The name of the directory (the last element of the path) | |
Name string | |
// The full path of the request | |
Path string | |
// Whether the parent directory is browsable | |
CanGoUp bool | |
// The items (files and folders) in the path | |
Items []FileInfo | |
// The number of directories in the listing | |
NumDirs int | |
// The number of files (items that aren't directories) in the listing | |
NumFiles int | |
// Which sorting order is used | |
Sort string | |
// And which order | |
Order string | |
// If ≠0 then Items have been limited to that many elements | |
ItemsLimitedTo int | |
// Optional custom variables for use in browse templates | |
User interface{} | |
httpserver.Context | |
} | |
// Crumb represents part of a breadcrumb menu. | |
type Crumb struct { | |
Link, Text string | |
} | |
// Breadcrumbs returns l.Path where every element maps | |
// the link to the text to display. | |
func (l Listing) Breadcrumbs() []Crumb { | |
var result []Crumb | |
if len(l.Path) == 0 { | |
return result | |
} | |
// skip trailing slash | |
lpath := l.Path | |
if lpath[len(lpath)-1] == '/' { | |
lpath = lpath[:len(lpath)-1] | |
} | |
parts := strings.Split(lpath, "/") | |
for i := range parts { | |
txt := parts[i] | |
if i == 0 && parts[i] == "" { | |
txt = "/" | |
} | |
result = append(result, Crumb{Link: strings.Repeat("../", len(parts)-i-1), Text: txt}) | |
} | |
return result | |
} | |
// HasExt returns whether the listing has one or more files with any of the specified suffixes | |
func (l Listing) HasExt(exts ...string) bool { | |
for _, item := range l.Items { | |
for _, ext := range exts { | |
if strings.HasSuffix(item.Name, ext) { | |
return true | |
} | |
} | |
} | |
return false | |
} | |
// HasItem returns whether the listing has an item with any of the specified names | |
func (l Listing) HasItem(names ...string) bool { | |
for _, item := range l.Items { | |
for _, name := range names { | |
if item.Name == name { | |
return true | |
} | |
} | |
} | |
return false | |
} | |
// HasMatch returns whether the listing has an item with a name matching the given regex | |
func (l Listing) HasMatch(pattern string) bool { | |
for _, item := range l.Items { | |
matched, _ := regexp.MatchString(pattern, item.Name) | |
if matched { | |
return true | |
} | |
} | |
return false | |
} | |
// FileInfo is the info about a particular file or directory | |
type FileInfo struct { | |
Name string | |
Size int64 | |
URL string | |
ModTime time.Time | |
Mode os.FileMode | |
IsDir bool | |
IsSymlink bool | |
} | |
// HumanSize returns the size of the file as a human-readable string | |
// in IEC format (i.e. power of 2 or base 1024). | |
func (fi FileInfo) HumanSize() string { | |
return humanize.IBytes(uint64(fi.Size)) | |
} | |
// HumanModTime returns the modified time of the file as a human-readable string. | |
func (fi FileInfo) HumanModTime(format string) string { | |
return fi.ModTime.Format(format) | |
} | |
// Implement sorting for Listing | |
func (l Listing) Len() int { return len(l.Items) } | |
func (l Listing) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } | |
func (l Listing) Less(i, j int) bool { | |
a := l.Items[i] | |
b := l.Items[j] | |
// if both are dir or file sort normally | |
if a.IsDir == b.IsDir { | |
if a.Name == "SHA256SUMS" && b.Name != "SHA256SUMS" { | |
return false | |
} else if a.Name != "SHA256SUMS" && b.Name == "SHA256SUMS" { | |
return true | |
} | |
return strings.ToLower(a.Name) < strings.ToLower(b.Name) | |
} | |
// always sort dir ahead of file | |
return a.IsDir | |
} | |
func directoryListing(files []os.FileInfo, canGoUp bool, urlPath string, config *Config) (Listing, bool) { | |
var ( | |
fileinfos []FileInfo | |
dirCount, fileCount int | |
hasIndexFile bool | |
) | |
for _, f := range files { | |
name := f.Name() | |
for _, indexName := range config.Fs.IndexPages { | |
if name == indexName { | |
hasIndexFile = true | |
break | |
} | |
} | |
isDir := f.IsDir() || isSymlinkTargetDir(f, urlPath, config) | |
if isDir { | |
name += "/" | |
dirCount++ | |
} else { | |
fileCount++ | |
} | |
if config.Fs.IsHidden(f) { | |
continue | |
} | |
url := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name | |
fileinfos = append(fileinfos, FileInfo{ | |
IsDir: isDir, | |
IsSymlink: isSymlink(f), | |
Name: f.Name(), | |
Size: f.Size(), | |
URL: url.String(), | |
ModTime: f.ModTime().UTC(), | |
Mode: f.Mode(), | |
}) | |
} | |
return Listing{ | |
Name: path.Base(urlPath), | |
Path: urlPath, | |
CanGoUp: canGoUp, | |
Items: fileinfos, | |
NumDirs: dirCount, | |
NumFiles: fileCount, | |
}, hasIndexFile | |
} | |
// isSymlink return true if f is a symbolic link | |
func isSymlink(f os.FileInfo) bool { | |
return f.Mode()&os.ModeSymlink != 0 | |
} | |
// isSymlinkTargetDir return true if f's symbolic link target | |
// is a directory. Return false if not a symbolic link. | |
func isSymlinkTargetDir(f os.FileInfo, urlPath string, config *Config) bool { | |
if !isSymlink(f) { | |
return false | |
} | |
// a bit strange, but we want Stat thru the jailed filesystem to be safe | |
target, err := config.Fs.Root.Open(path.Join(urlPath, f.Name())) | |
if err != nil { | |
return false | |
} | |
defer target.Close() | |
targetInfo, err := target.Stat() | |
if err != nil { | |
return false | |
} | |
return targetInfo.IsDir() | |
} | |
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. | |
// If so, control is handed over to ServeListing. | |
func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { | |
// See if there's a browse configuration to match the path | |
var bc *Config | |
for i := range b.Configs { | |
if httpserver.Path(r.URL.Path).Matches(b.Configs[i].PathScope) { | |
bc = &b.Configs[i] | |
break | |
} | |
} | |
if bc == nil { | |
return b.Next.ServeHTTP(w, r) | |
} | |
// Browse works on existing directories; delegate everything else | |
requestedFilepath, err := bc.Fs.Root.Open(r.URL.Path) | |
if err != nil { | |
switch { | |
case os.IsPermission(err): | |
return http.StatusForbidden, err | |
case os.IsExist(err): | |
return http.StatusNotFound, err | |
default: | |
return b.Next.ServeHTTP(w, r) | |
} | |
} | |
defer requestedFilepath.Close() | |
info, err := requestedFilepath.Stat() | |
if err != nil { | |
switch { | |
case os.IsPermission(err): | |
return http.StatusForbidden, err | |
case os.IsExist(err): | |
return http.StatusGone, err | |
default: | |
return b.Next.ServeHTTP(w, r) | |
} | |
} | |
if !info.IsDir() { | |
return b.Next.ServeHTTP(w, r) | |
} | |
// Do not reply to anything else because it might be nonsensical | |
switch r.Method { | |
case http.MethodGet, http.MethodHead: | |
// proceed, noop | |
case "PROPFIND", http.MethodOptions: | |
return http.StatusNotImplemented, nil | |
default: | |
return b.Next.ServeHTTP(w, r) | |
} | |
// Browsing navigation gets messed up if browsing a directory | |
// that doesn't end in "/" (which it should, anyway) | |
u := *r.URL | |
if u.Path == "" { | |
u.Path = "/" | |
} | |
if u.Path[len(u.Path)-1] != '/' { | |
u.Path += "/" | |
http.Redirect(w, r, u.String(), http.StatusMovedPermanently) | |
return http.StatusMovedPermanently, nil | |
} | |
return b.ServeListing(w, r, requestedFilepath, bc) | |
} | |
func (b Browse) loadDirectoryContents(requestedFilepath http.File, urlPath string, config *Config) (*Listing, bool, error) { | |
files, err := requestedFilepath.Readdir(-1) | |
if err != nil { | |
return nil, false, err | |
} | |
// Determine if user can browse up another folder | |
var canGoUp bool | |
curPathDir := path.Dir(strings.TrimSuffix(urlPath, "/")) | |
for _, other := range b.Configs { | |
if strings.HasPrefix(curPathDir, other.PathScope) { | |
canGoUp = true | |
break | |
} | |
} | |
// Assemble listing of directory contents | |
listing, hasIndex := directoryListing(files, canGoUp, urlPath, config) | |
return &listing, hasIndex, nil | |
} | |
// ServeListing returns a formatted view of 'requestedFilepath' contents'. | |
func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFilepath http.File, bc *Config) (int, error) { | |
listing, containsIndex, err := b.loadDirectoryContents(requestedFilepath, r.URL.Path, bc) | |
if err != nil { | |
switch { | |
case os.IsPermission(err): | |
return http.StatusForbidden, err | |
case os.IsExist(err): | |
return http.StatusGone, err | |
default: | |
return http.StatusInternalServerError, err | |
} | |
} | |
if containsIndex && !b.IgnoreIndexes { // directory isn't browsable | |
return b.Next.ServeHTTP(w, r) | |
} | |
listing.Context = httpserver.Context{ | |
Root: bc.Fs.Root, | |
Req: r, | |
URL: r.URL, | |
} | |
listing.User = bc.Variables | |
sort.Sort(listing) | |
var buf *bytes.Buffer | |
acceptHeader := strings.ToLower(strings.Join(r.Header["Accept"], ",")) | |
switch { | |
case strings.Contains(acceptHeader, "application/json"): | |
if buf, err = b.formatAsJSON(listing, bc); err != nil { | |
return http.StatusInternalServerError, err | |
} | |
w.Header().Set("Content-Type", "application/json; charset=utf-8") | |
default: // There's no 'application/json' in the 'Accept' header; browse normally | |
if buf, err = b.formatAsHTML(listing, bc); err != nil { | |
return http.StatusInternalServerError, err | |
} | |
w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
} | |
buf.WriteTo(w) | |
return http.StatusOK, nil | |
} | |
func (b Browse) formatAsJSON(listing *Listing, bc *Config) (*bytes.Buffer, error) { | |
marsh, err := json.Marshal(listing.Items) | |
if err != nil { | |
return nil, err | |
} | |
buf := new(bytes.Buffer) | |
_, err = buf.Write(marsh) | |
return buf, err | |
} | |
func (b Browse) formatAsHTML(listing *Listing, bc *Config) (*bytes.Buffer, error) { | |
buf := new(bytes.Buffer) | |
err := bc.Template.Execute(buf, listing) | |
return buf, err | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>~/Media{{.Path}}</title> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<link rel="stylesheet" type="text/css" href="/resources/style.css"/> | |
{{define "svg_folder"}} | |
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" /></svg> | |
{{end}} | |
{{define "svg_video"}} | |
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M18,4L20,8H17L15,4H13L15,8H12L10,4H8L10,8H7L5,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V4H18Z" /></svg> | |
{{end}} | |
{{define "svg_file"}} | |
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z" /></svg> | |
{{end}} | |
{{define "svg_hash"}} | |
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M3,5A2,2 0 0,1 5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5C3.89,21 3,20.1 3,19V5M7,18H9L9.35,16H13.35L13,18H15L15.35,16H17.35L17.71,14H15.71L16.41,10H18.41L18.76,8H16.76L17.12,6H15.12L14.76,8H10.76L11.12,6H9.12L8.76,8H6.76L6.41,10H8.41L7.71,14H5.71L5.35,16H7.35L7,18M10.41,10H14.41L13.71,14H9.71L10.41,10Z" /></svg> | |
{{end}} | |
{{define "svg_movie"}} | |
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M18,9H16V7H18M18,13H16V11H18M18,17H16V15H18M8,9H6V7H8M8,13H6V11H8M8,17H6V15H8M18,3V5H16V3H8V5H6V3H4V21H6V19H8V21H16V19H18V21H20V3H18Z" /></svg> | |
{{end}} | |
{{define "svg_tv"}} | |
<svg class="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M8.16,3L6.75,4.41L9.34,7H4C2.89,7 2,7.89 2,9V19C2,20.11 2.89,21 4,21H20C21.11,21 22,20.11 22,19V9C22,7.89 21.11,7 20,7H14.66L17.25,4.41L15.84,3L12,6.84L8.16,3M4,9H17V19H4V9M19.5,9A1,1 0 0,1 20.5,10A1,1 0 0,1 19.5,11A1,1 0 0,1 18.5,10A1,1 0 0,1 19.5,9M19.5,12A1,1 0 0,1 20.5,13A1,1 0 0,1 19.5,14A1,1 0 0,1 18.5,13A1,1 0 0,1 19.5,12Z" /></svg> | |
{{end}} | |
</head> | |
<body> | |
<header> | |
<h1> | |
~ /{{range $i, $crumb := .Breadcrumbs}}<a href="{{html $crumb.Link}}">{{if eq $i 0}}Media{{else}}{{html $crumb.Text}}{{end}}</a>/{{end}} | |
</h1> | |
<div id="meta"> | |
<input type="text" placeholder="Search" id="filter" onkeyup='filter()'> | |
</div> | |
</header> | |
<main> | |
{{$notTor := ne .Host "andrew2cezcusdq3.onion"}} | |
{{$isMovies := .PathMatches "/Movies"}} | |
{{$noVideos := and (not (.HasExt ".mkv" ".mp4")) (ne .Path "/")}} | |
{{if and $notTor (or $isMovies $noVideos)}} | |
<div id="shelf"> | |
{{range .Items}} | |
{{if or .IsDir (eq ($.Ext .Name) ".mkv" ".mp4" ".txt")}} | |
<a class="coverart" href="{{html .URL}}"> | |
{{if $.PathMatches "/TV"}} | |
<img src="{{html .URL}}poster.jpg" alt=""> | |
{{else}} | |
<img src="/resources/movie_thumbs/{{html ($.StripExt .Name)}}.png" alt=""> | |
{{end}} | |
<span class="name">{{if .IsDir}}{{html .Name}}{{else}}{{html ($.StripExt .Name)}}{{end}}</span> | |
</a> | |
{{end}} | |
{{end}} | |
</div> | |
{{if .HasItem "SHA256SUMS"}} | |
<a href="./SHA256SUMS"> | |
<p style="text-align: center; margin-top:1em; margin-bottom: 1.5em"> | |
SHA-256 Checksums | |
</p> | |
</a> | |
{{end}} | |
{{else}} | |
<div id="listing"> | |
<table aria-describedby="summary"> | |
<thead> | |
<tr> | |
<th>Name</th> | |
<th class="hideable">Size</th> | |
</tr> | |
</thead> | |
<tbody> | |
{{range .Items}} | |
{{if and .IsDir (ne .Name "resources")}} | |
<tr class="file"> | |
<td> | |
<a href="{{html .URL}}"> | |
{{if eq .Name "Movies"}} | |
{{template "svg_movie"}} | |
{{else if eq .Name "TV"}} | |
{{template "svg_tv"}} | |
{{else}} | |
{{template "svg_folder"}} | |
{{end}} | |
<span class="name">{{html .Name}}</span> | |
</a> | |
</td> | |
<td class="hideable">—</td> | |
</tr> | |
{{else if or (eq ($.Ext .Name) ".mkv" ".mp4" ".txt") (eq .Name "SHA256SUMS")}} | |
<tr class="file"> | |
<td> | |
<a href="{{html .URL}}"> | |
{{if eq ($.Ext .Name) ".mkv" ".mp4"}} | |
{{template "svg_video"}} | |
{{else if eq .Name "SHA256SUMS"}} | |
{{template "svg_hash"}} | |
{{else}} | |
{{template "svg_file"}} | |
{{end}} | |
<span class="name"> | |
{{- if eq .Name "SHA256SUMS" -}} | |
SHA-256 Checksums | |
{{- else -}} | |
{{- html ($.StripExt .Name) -}} | |
{{- end -}} | |
</span> | |
</a> | |
</td> | |
<td class="hideable">{{.HumanSize}}</td> | |
</tr> | |
{{end}} | |
{{end}} | |
</tbody> | |
</table> | |
</div> | |
{{end}} | |
</main> | |
<script> | |
var filterEl = document.getElementById('filter'); | |
filterEl.focus(); | |
function filter() { | |
var q = filterEl.value.trim().toLowerCase(); | |
var elems = document.querySelectorAll('tr.file'); | |
if (elems.length == 0) { | |
elems = document.querySelectorAll('.coverart'); | |
} | |
elems.forEach(function(el) { | |
if (!q) { | |
el.style.display = ''; | |
return; | |
} | |
var nameEl = el.querySelector('.name'); | |
var nameVal = nameEl.textContent.trim().toLowerCase(); | |
if (nameVal.indexOf(q) !== -1) { | |
el.style.display = ''; | |
} else { | |
el.style.display = 'none'; | |
} | |
}); | |
} | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
* { | |
padding: 0; | |
margin: 0; | |
} | |
body { | |
font-family: sans-serif; | |
text-rendering: optimizespeed; | |
background: #212121; | |
} | |
a { | |
color: #bbb; | |
text-decoration: none; | |
} | |
a:hover { | |
} | |
header { | |
padding-left: 5%; | |
padding-right: 5%; | |
padding-top: 10px; | |
padding-bottom: 10px; | |
background-color: #0f0f0f; | |
border-bottom: 1px solid #9C9C9C; | |
} | |
th:first-child, | |
td:first-child { | |
padding-left: 5%; | |
} | |
th:last-child, | |
td:last-child { | |
padding-right: 5%; | |
} | |
h1 { | |
font-size: 20px; | |
font-weight: normal; | |
white-space: nowrap; | |
overflow-x: hidden; | |
text-overflow: ellipsis; | |
color: #7f7f7f; | |
opacity: 1; | |
} | |
h1 a { | |
color: #fff; | |
margin: 0.35em 0.35em; | |
} | |
h1 a:hover { | |
text-decoration: underline; | |
} | |
main { | |
display: block; | |
} | |
#meta { | |
font-size: 12px; | |
font-family: Verdana, sans-serif; | |
padding-top: 10px; | |
} | |
#filter { | |
box-sizing: border-box; | |
padding: 4px; | |
border: 1px solid #ccc; | |
background-color: #000; | |
color: #afafaf; | |
max-width: 500px; | |
width: 90%; | |
display: block; | |
margin: auto; | |
} | |
table { | |
width: 100%; | |
border-collapse: collapse; | |
} | |
tr { | |
border-bottom: 1px dashed #3f3f3f; | |
} | |
tbody tr:hover { | |
background-color: #303030; | |
} | |
th, | |
td { | |
text-align: left; | |
padding: 10px 0; | |
} | |
th { | |
padding-top: 15px; | |
padding-bottom: 15px; | |
margin-right: 5%; | |
font-size: 16px; | |
white-space: nowrap; | |
color: #fff; | |
opacity: 0.5; | |
} | |
td { | |
white-space: nowrap; | |
font-size: 14px; | |
} | |
td:first-child { | |
width: auto; | |
} | |
th:last-child, | |
td:last-child { | |
text-align: right; | |
} | |
td:first-child svg { | |
position: absolute; | |
} | |
td .name, | |
tr .hideable { | |
margin-left: 1.75em; | |
word-break: break-all; | |
overflow-wrap: break-word; | |
white-space: pre-wrap; | |
color: #fff; | |
opacity: 0.7; | |
} | |
td:hover .icon, | |
td:hover a .name, | |
tbody tr:hover .hideable { | |
color:#fff; | |
opacity: 1.0; | |
} | |
.coverart { | |
width: 200px; | |
display: flex; | |
margin: 10px; | |
box-shadow: 0px 1px 4px 2px rgba(0,0,0,0.2); | |
transition: all 0.2s cubic-bezier(0.0, 0.0, 0.2, 1); | |
user-select: none; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; | |
cursor: pointer; | |
transform: translateZ(0); | |
min-height: 250px; | |
background-color: #000; | |
} | |
.coverart:hover { | |
transform: scale(1.2); | |
z-index: 9999; | |
box-shadow: 0px 1px 8px 4px rgba(0,0,0,0.2); | |
} | |
.coverart:active { | |
transform: scale(0.96); | |
} | |
.coverart img { | |
width: 100%; | |
height: 100%; | |
pointer-events: none; | |
} | |
#shelf { | |
display: flex; | |
flex-direction: row; | |
justify-content: center; | |
align-items: center; | |
align-content: center; | |
flex: 1; | |
flex-wrap: wrap; | |
margin: auto; | |
margin-top: 10px; | |
margin-bottom:25px; | |
max-width: 1200px; | |
flex-basis: auto; | |
} | |
.icon { | |
width: 1.5em; | |
height: 1.5em; | |
opacity: 0.38; | |
} | |
@media (max-width: 600px) { | |
.hideable { | |
display: none; | |
} | |
.coverart { | |
max-width: 40%; | |
height: 100%; | |
min-height: 100px; | |
} | |
.shelf { | |
max-width: 90%; | |
} | |
} | |
.coverart .name { | |
position: absolute; | |
text-align: center; | |
margin-left: 0; | |
margin-right: 0; | |
width: 100%; | |
z-index: -9999; | |
color: #fff; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment