basic web ui
This commit is contained in:
parent
58a99cd74a
commit
d2059240dd
|
@ -0,0 +1,46 @@
|
|||
module.exports = function(grunt) {
|
||||
grunt.initConfig({
|
||||
less: {
|
||||
website: {
|
||||
files: {
|
||||
'styles/css/main.css': 'styles/less/main.less'
|
||||
}
|
||||
}
|
||||
},
|
||||
cssmin: {
|
||||
website: {
|
||||
files: {
|
||||
'styles/mincss/pure.css': 'node_modules/purecss/build/pure.css',
|
||||
'styles/mincss/grids-responsive-min.css': 'node_modules/purecss/build/grids-responsive.css',
|
||||
'styles/mincss/main.css': 'styles/css/main.css'
|
||||
}
|
||||
}
|
||||
},
|
||||
concat: {
|
||||
dist: {
|
||||
src: [
|
||||
'styles/mincss/pure.css',
|
||||
'styles/mincss/grids-responsive-min.css',
|
||||
'styles/mincss/main.css'
|
||||
],
|
||||
dest: 'styles/dist/style.css',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
less: {
|
||||
files: ['styles/less/{,*/}*.less'],
|
||||
tasks: ['less:website', 'cssmin:website', 'concat:dist'],
|
||||
options: {
|
||||
spawn: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
grunt.loadNpmTasks('grunt-contrib-less');
|
||||
grunt.loadNpmTasks('grunt-contrib-watch');
|
||||
grunt.loadNpmTasks('grunt-contrib-cssmin');
|
||||
grunt.loadNpmTasks('grunt-contrib-concat');
|
||||
|
||||
grunt.registerTask('default', ['less:website', 'cssmin:website', 'concat:dist', 'watch']);
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,198 @@
|
|||
import os
|
||||
import cherrypy
|
||||
import logging
|
||||
from photoapp.library import PhotoLibrary
|
||||
from photoapp.types import Photo, PhotoSet
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
class PhotosWeb(object):
|
||||
def __init__(self, library):
|
||||
self.library = library
|
||||
self.tpl = Environment(loader=FileSystemLoader('templates'),
|
||||
autoescape=select_autoescape(['html', 'xml']))
|
||||
self.tpl.globals.update(mime2ext=self.mime2ext)
|
||||
self.thumb = ThumbnailView(self)
|
||||
self.photo = PhotoView(self)
|
||||
self.download = DownloadView(self)
|
||||
|
||||
def session(self):
|
||||
return self.library.session()
|
||||
|
||||
@staticmethod
|
||||
def mime2ext(mime):
|
||||
return {"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"application/octet-stream-xmp": "xmp",
|
||||
"image/x-canon-cr2": "cr2",
|
||||
"video/mp4": "mp4"}[mime]
|
||||
|
||||
@cherrypy.expose
|
||||
def index(self):
|
||||
raise cherrypy.HTTPRedirect('feed', 302)
|
||||
|
||||
@cherrypy.expose
|
||||
def feed(self, page=0, pgsize=25):
|
||||
s = self.session()
|
||||
page, pgsize = int(page), int(pgsize)
|
||||
images = s.query(PhotoSet).order_by(PhotoSet.date.desc()).offset(pgsize * page).limit(pgsize).all()
|
||||
yield self.tpl.get_template("feed.html").render(images=[i for i in images], page=page)
|
||||
|
||||
@cherrypy.expose
|
||||
def monthly(self):
|
||||
s = self.session()
|
||||
images = s.query(func.count(PhotoSet.uuid),
|
||||
func.strftime('%Y', PhotoSet.date).label('year'),
|
||||
func.strftime('%m', PhotoSet.date).label('month')). \
|
||||
group_by('year', 'month').order_by('year', 'month').all()
|
||||
|
||||
yield self.tpl.get_template("monthly.html").render(images=images)
|
||||
|
||||
|
||||
@cherrypy.popargs('item_type', 'thumb_size', 'uuid')
|
||||
class ThumbnailView(object):
|
||||
def __init__(self, master):
|
||||
self.master = master
|
||||
self._cp_config = {"tools.trailing_slash.on": False}
|
||||
|
||||
@cherrypy.expose
|
||||
def index(self, item_type, thumb_size, uuid):
|
||||
uuid = uuid.split(".")[0]
|
||||
s = self.master.session()
|
||||
|
||||
query = s.query(Photo).filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
|
||||
else s.query(Photo).filter(Photo.uuid == uuid) if item_type == "one" \
|
||||
else None
|
||||
|
||||
# prefer making thumbs from jpeg to avoid loading large raws
|
||||
first = None
|
||||
best = None
|
||||
for photo in query.all():
|
||||
if first is None:
|
||||
first = photo
|
||||
if photo.format == "image/jpeg":
|
||||
best = photo
|
||||
break
|
||||
thumb_from = best or first
|
||||
if not thumb_from:
|
||||
raise Exception("404") # TODO it right
|
||||
# TODO some lock around calls to this based on uuid
|
||||
thumb_path = self.master.library.make_thumb(thumb_from, thumb_size)
|
||||
if thumb_path:
|
||||
return cherrypy.lib.static.serve_file(thumb_path, 'image/jpeg')
|
||||
else:
|
||||
return "No thumbnail available" # TODO generic svg icon
|
||||
|
||||
|
||||
@cherrypy.popargs('item_type', 'uuid')
|
||||
class DownloadView(object):
|
||||
def __init__(self, master):
|
||||
self.master = master
|
||||
self._cp_config = {"tools.trailing_slash.on": False}
|
||||
|
||||
@cherrypy.expose
|
||||
def index(self, item_type, uuid, preview=False):
|
||||
uuid = uuid.split(".")[0]
|
||||
s = self.master.session()
|
||||
|
||||
query = None if item_type == "set" \
|
||||
else s.query(Photo).filter(Photo.uuid == uuid) if item_type == "one" \
|
||||
else None # TODO set download query
|
||||
|
||||
item = query.first()
|
||||
extra = {}
|
||||
if not preview:
|
||||
extra.update(disposition="attachement", name=os.path.basename(item.path))
|
||||
return cherrypy.lib.static.serve_file(os.path.abspath(os.path.join(self.master.library.path, item.path)),
|
||||
content_type=item.format, **extra)
|
||||
|
||||
|
||||
@cherrypy.popargs('uuid')
|
||||
class PhotoView(object):
|
||||
def __init__(self, master):
|
||||
self.master = master
|
||||
self._cp_config = {"tools.trailing_slash.on": False}
|
||||
|
||||
@cherrypy.expose
|
||||
def index(self, uuid):
|
||||
uuid = uuid.split(".")[0]
|
||||
s = self.master.session()
|
||||
photo = s.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
|
||||
|
||||
yield self.master.tpl.get_template("photo.html").render(image=photo)
|
||||
|
||||
# yield "viewing {}".format(uuid)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
import signal
|
||||
|
||||
parser = argparse.ArgumentParser(description="Photod photo server")
|
||||
|
||||
parser.add_argument('-p', '--port', default=8080, type=int, help="tcp port to listen on")
|
||||
parser.add_argument('-l', '--library', default="./library", help="library path")
|
||||
parser.add_argument('-s', '--database-path', default="./photos.db", help="path to persistent sqlite database")
|
||||
parser.add_argument('--debug', action="store_true", help="enable development options")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
|
||||
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
||||
|
||||
library = PhotoLibrary(args.database_path, args.library)
|
||||
|
||||
web = PhotosWeb(library)
|
||||
web_config = {}
|
||||
|
||||
# TODO make auth work again
|
||||
if True or args.disable_auth:
|
||||
logging.warning("starting up with auth disabled")
|
||||
else:
|
||||
def validate_password(realm, username, password):
|
||||
print("I JUST VALIDATED {}:{} ({})".format(username, password, realm))
|
||||
return True
|
||||
|
||||
web_config.update({'tools.auth_basic.on': True,
|
||||
'tools.auth_basic.realm': 'pysonic',
|
||||
'tools.auth_basic.checkpassword': validate_password})
|
||||
|
||||
cherrypy.tree.mount(web, '/', {'/': web_config,
|
||||
'/static': {"tools.staticdir.on": True,
|
||||
"tools.staticdir.dir": os.path.abspath("./styles/dist")}})
|
||||
|
||||
cherrypy.config.update({
|
||||
'sessionFilter.on': True,
|
||||
'tools.sessions.on': True,
|
||||
'tools.sessions.locking': 'explicit',
|
||||
'tools.sessions.timeout': 525600,
|
||||
'tools.gzip.on': True,
|
||||
'request.show_tracebacks': True,
|
||||
'server.socket_port': args.port,
|
||||
'server.thread_pool': 25,
|
||||
'server.socket_host': '0.0.0.0',
|
||||
'server.show_tracebacks': True,
|
||||
'server.socket_timeout': 5,
|
||||
'log.screen': False,
|
||||
'engine.autoreload.on': args.debug
|
||||
})
|
||||
|
||||
def signal_handler(signum, stack):
|
||||
logging.critical('Got sig {}, exiting...'.format(signum))
|
||||
cherrypy.engine.exit()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
cherrypy.engine.start()
|
||||
cherrypy.engine.block()
|
||||
finally:
|
||||
logging.info("API has shut down")
|
||||
cherrypy.engine.exit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -11,29 +11,22 @@ def get_jpg_info(fpath):
|
|||
"""
|
||||
Given the path to a jpg, return a dict describing it
|
||||
"""
|
||||
date, gps = get_exif_data(fpath)
|
||||
date, gps, dimensions = get_exif_data(fpath)
|
||||
|
||||
if not date:
|
||||
# No exif date, fall back to file modification date
|
||||
date = get_mtime(fpath)
|
||||
if date is None:
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
raise Exception("fuk")
|
||||
|
||||
# gps is set to 0,0 if unavailable
|
||||
lat, lon = gps or [0, 0]
|
||||
|
||||
dimensions = dimensions or (0, 0)
|
||||
mime = magic.from_file(fpath, mime=True)
|
||||
size = os.path.getsize(fpath)
|
||||
|
||||
# ps = PhotoSet
|
||||
|
||||
photo = Photo(hash=get_hash(fpath), path=fpath, format=mime)
|
||||
# "fname": os.path.basename(fpath),
|
||||
|
||||
photo = Photo(hash=get_hash(fpath), path=fpath, format=mime, size=size, width=dimensions[0], height=dimensions[1])
|
||||
return PhotoSet(date=date, lat=lat, lon=lon, files=[photo])
|
||||
|
||||
# return {"date": date,
|
||||
# "lat": lat,
|
||||
# "lon": lon,
|
||||
# "formats": []}
|
||||
|
||||
|
||||
def get_mtime(fpath):
|
||||
return datetime.fromtimestamp(os.stat(fpath).st_mtime)
|
||||
|
@ -55,19 +48,21 @@ def get_exif_data(path):
|
|||
Return a (datetime, (decimal, decimal)) tuple describing the photo's exif date and gps coordinates
|
||||
"""
|
||||
img = Image.open(path)
|
||||
if img.format != "JPEG":
|
||||
return None, None
|
||||
|
||||
datestr = None
|
||||
gpsinfo = None
|
||||
dateinfo = None
|
||||
sizeinfo = (img.width, img.height)
|
||||
|
||||
if img.format in ["JPEG", "PNG", "GIF"]:
|
||||
if hasattr(img, "_getexif"):
|
||||
exif_data = img._getexif()
|
||||
if not exif_data:
|
||||
return None, None
|
||||
if exif_data:
|
||||
exif = {
|
||||
ExifTags.TAGS[k]: v
|
||||
for k, v in exif_data.items()
|
||||
if k in ExifTags.TAGS
|
||||
}
|
||||
datestr = None
|
||||
gpsinfo = None
|
||||
dateinfo = None
|
||||
acceptable = ["DateTime", "DateTimeOriginal", "DateTimeDigitized"]
|
||||
for key in acceptable:
|
||||
if key in exif:
|
||||
|
@ -89,8 +84,16 @@ def get_exif_data(path):
|
|||
if gps[3] == 'W':
|
||||
gps_x *= -1
|
||||
gpsinfo = (gps_y, gps_x)
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
pass
|
||||
pass
|
||||
pass
|
||||
|
||||
return dateinfo, gpsinfo
|
||||
if dateinfo is None:
|
||||
dateinfo = get_mtime(path)
|
||||
|
||||
return dateinfo, gpsinfo, sizeinfo
|
||||
|
||||
|
||||
def rational64u_to_hms(values):
|
||||
|
@ -101,3 +104,7 @@ def rational64u_to_hms(values):
|
|||
|
||||
def hms_to_decimal(values):
|
||||
return values[0] + values[1] / 60 + values[2] / 3600
|
||||
|
||||
|
||||
def main():
|
||||
print(get_exif_data("library/2018/9/8/MMwo4hr.jpg"))
|
||||
|
|
|
@ -55,7 +55,8 @@ def batch_ingest(library, files):
|
|||
print("Scanning RAWs")
|
||||
# process raws
|
||||
for item in chain(*[byext[ext] for ext in files_raw]):
|
||||
itemmeta = Photo(hash=get_hash(item), path=item, format=magic.from_file(item, mime=True))
|
||||
itemmeta = Photo(hash=get_hash(item), path=item, size=os.path.getsize(item),
|
||||
format=special_magic(item))
|
||||
fprefix = os.path.basename(item)[::-1].split(".", 1)[-1][::-1]
|
||||
fmatch = "{}.jpg".format(fprefix.lower())
|
||||
foundmatch = False
|
||||
|
@ -67,21 +68,28 @@ def batch_ingest(library, files):
|
|||
break
|
||||
if foundmatch:
|
||||
break
|
||||
|
||||
if not foundmatch:
|
||||
photos.append(PhotoSet(date=get_mtime(item), lat=0, lon=0, files=[itemmeta]))
|
||||
|
||||
# TODO prune any xmp without an associated regular image or cr2
|
||||
|
||||
print("Scanning other files")
|
||||
# process all other formats
|
||||
for item in chain(*[byext[ext] for ext in files_video]):
|
||||
itemmeta = Photo(hash=get_hash(item), path=item, format=magic.from_file(item, mime=True))
|
||||
itemmeta = Photo(hash=get_hash(item), path=item, size=os.path.getsize(item),
|
||||
format=special_magic(item))
|
||||
photos.append(PhotoSet(date=get_mtime(item), lat=0, lon=0, files=[itemmeta]))
|
||||
|
||||
print("Updating database")
|
||||
for photoset in photos:
|
||||
library.add_photoset(photoset)
|
||||
print("Update complete")
|
||||
|
||||
|
||||
def special_magic(fpath):
|
||||
if fpath.split(".")[-1].lower() == "xmp":
|
||||
return "application/octet-stream-xmp"
|
||||
else:
|
||||
return magic.from_file(fpath, mime=True)
|
||||
|
||||
|
||||
def main():
|
||||
|
|
|
@ -1,17 +1,27 @@
|
|||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from time import time
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from photoapp.types import Base, Photo, PhotoSet
|
||||
from photoapp.types import Base, Photo, PhotoSet # need to be loaded for orm setup
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from collections import defaultdict
|
||||
from multiprocessing import Process
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
class PhotoLibrary(object):
|
||||
def __init__(self, db_path, lib_path):
|
||||
self.path = lib_path
|
||||
self.engine = create_engine('sqlite:///{}'.format(db_path), echo=False)
|
||||
self.cache_path = "./cache" # TODO param
|
||||
self.engine = create_engine('sqlite:///{}'.format(db_path),
|
||||
connect_args={'check_same_thread': False}, poolclass=StaticPool, echo=False)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = sessionmaker()
|
||||
self.session.configure(bind=self.engine)
|
||||
self._failed_thumbs_cache = defaultdict(dict)
|
||||
|
||||
def add_photoset(self, photoset):
|
||||
"""
|
||||
|
@ -54,3 +64,43 @@ class PhotoLibrary(object):
|
|||
Return a path like 2018/3/31 given a datetime object representing the same date
|
||||
"""
|
||||
return os.path.join(str(date.year), str(date.month), str(date.day))
|
||||
|
||||
def make_thumb(self, photo, style):
|
||||
"""
|
||||
Create a thumbnail of the given photo, scaled/cropped to the given named style
|
||||
:return: local path to thumbnail file or None if creation failed or was blocked
|
||||
"""
|
||||
styles = {"feed": (250, 250),
|
||||
"preview": (1024, 768),
|
||||
"big": (2048, 1536)}
|
||||
dest = os.path.join(self.cache_path, "thumbs", style, "{}.jpg".format(photo.uuid))
|
||||
if os.path.exists(dest):
|
||||
return os.path.abspath(dest)
|
||||
if photo.uuid not in self._failed_thumbs_cache[style]:
|
||||
width = min(styles[style][0], photo.width if photo.width > 0 else 999999999)
|
||||
height = min(styles[style][1], photo.height if photo.height > 0 else 999999999) # TODO this is bad.
|
||||
p = Process(target=self.gen_thumb, args=(os.path.join(self.path, photo.path), dest, width, height))
|
||||
p.start()
|
||||
p.join()
|
||||
if p.exitcode != 0:
|
||||
self._failed_thumbs_cache[style][photo.uuid] = True # dont retry failed generations
|
||||
return None
|
||||
return os.path.abspath(dest)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def gen_thumb(src_img, dest_img, width, height):
|
||||
try:
|
||||
start = time()
|
||||
# TODO lock around the dir creation
|
||||
os.makedirs(os.path.split(dest_img)[0], exist_ok=True)
|
||||
|
||||
image = Image.open(src_img)
|
||||
thumb = ImageOps.fit(image, (width, height), Image.ANTIALIAS)
|
||||
thumb.save(dest_img, 'JPEG')
|
||||
print("Generated {} in {}s".format(dest_img, round(time() - start, 4)))
|
||||
except:
|
||||
traceback.print_exc()
|
||||
if os.path.exists(dest_img):
|
||||
os.unlink(dest_img)
|
||||
sys.exit(1)
|
||||
|
|
|
@ -29,6 +29,9 @@ class Photo(Base):
|
|||
|
||||
set = relationship("PhotoSet", back_populates="files", foreign_keys=[set_id])
|
||||
|
||||
size = Column(Integer)
|
||||
width = Column(Integer)
|
||||
height = Column(Integer)
|
||||
hash = Column(String(length=64), unique=True)
|
||||
path = Column(Unicode)
|
||||
format = Column(String(length=64)) # TODO how long can a mime string be
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import argparse
|
||||
from photoapp.library import PhotoLibrary
|
||||
from photoapp.image import get_hash
|
||||
from photoapp.types import Photo
|
||||
import os
|
||||
|
||||
|
||||
def validate_all(library):
|
||||
|
||||
total = 0
|
||||
s = library.session()
|
||||
for item in s.query(Photo).order_by(Photo.date).all():
|
||||
assert item.hash == get_hash(os.path.join(library.path, item.path))
|
||||
print("ok ", item.path)
|
||||
total += 1
|
||||
print(total, "images verified")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Library verification tool")
|
||||
parser.parse_args()
|
||||
library = PhotoLibrary("photos.db", "./library/")
|
||||
validate_all(library)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
4
setup.py
4
setup.py
|
@ -17,6 +17,8 @@ setup(name='photoapp',
|
|||
entry_points={
|
||||
"console_scripts": [
|
||||
"photoappd = photoapp.daemon:main",
|
||||
"photoimport = photoapp.ingest:main"
|
||||
"photoimport = photoapp.ingest:main",
|
||||
"photovalidate = photoapp.validate:main",
|
||||
"photoinfo = photoapp.image:main"
|
||||
]
|
||||
})
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #1b98f8;
|
||||
}
|
||||
|
||||
#layout, #nav, #list, #main {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* make the navigation 100% width on phones */
|
||||
#nav {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
background: rgb(37, 42, 58);
|
||||
text-align: center;
|
||||
}
|
||||
/* show the "Menu" button on phones */
|
||||
#nav .nav-menu-button {
|
||||
display: block;
|
||||
top: 0.5em;
|
||||
right: 0.5em;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* when "Menu" is clicked, the navbar should be 80% height */
|
||||
#nav.active {
|
||||
height: 80%;
|
||||
}
|
||||
/* don't show the navigation items until the "Menu" button is clicked */
|
||||
.nav-inner {
|
||||
display: none;
|
||||
}
|
||||
#nav.active .nav-inner {
|
||||
display: block;
|
||||
padding: 2em 0;
|
||||
}
|
||||
|
||||
|
||||
#nav .pure-menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
#nav .pure-menu-link:hover,
|
||||
#nav .pure-menu-link:focus {
|
||||
background: rgb(55, 60, 90);
|
||||
}
|
||||
#nav .pure-menu-link {
|
||||
color: #fff;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
#nav .pure-menu-heading {
|
||||
border-bottom: none;
|
||||
font-size:110%;
|
||||
color: rgb(75, 113, 151);
|
||||
}
|
||||
|
||||
.email-label-personal,
|
||||
.email-label-work,
|
||||
.email-label-travel {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.email-label-personal {
|
||||
background: #ffc94c;
|
||||
}
|
||||
.email-label-work {
|
||||
background: #41ccb4;
|
||||
}
|
||||
.email-label-travel {
|
||||
background: #40c365;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
padding: 0em 4em;
|
||||
}
|
||||
.email-content-header, .email-content-body, .email-content-footer {
|
||||
padding: 1em 0em;
|
||||
}
|
||||
.email-content-header {
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.email-content-title {
|
||||
margin: 0.5em 0 0;
|
||||
}
|
||||
.email-content-subtitle {
|
||||
font-size: 1em;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.email-content-controls {
|
||||
margin-top: 2em;
|
||||
text-align: right;
|
||||
}
|
||||
.email-content-controls .secondary-button {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.email-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 40em) {
|
||||
#layout {
|
||||
padding-left: 300px; /* "left col (nav + list)" width */
|
||||
position: relative;
|
||||
}
|
||||
/* These are position:fixed; elements that will be in the left 500px of the screen */
|
||||
#nav, #list {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
#nav {
|
||||
margin-left:-300px; /* "left col (nav + list)" width */
|
||||
width:300px;
|
||||
height: 100%;
|
||||
}
|
||||
.nav-inner {
|
||||
display: block;
|
||||
padding: 2em 0;
|
||||
}
|
||||
#nav .nav-menu-button {
|
||||
display: none;
|
||||
}
|
||||
#main {
|
||||
position: fixed;
|
||||
top: 33%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 150px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 60em) {
|
||||
|
||||
/* This will now take up it's own column, so don't need position: fixed; */
|
||||
#main {
|
||||
position: static;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.photo-feed {
|
||||
.photo {
|
||||
float: left;
|
||||
width: 250px;
|
||||
height: 250px; // matches the "feed" thumb size in library.py
|
||||
overflow: hidden;
|
||||
margin: 0px 20px 20px 0px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: 3px solid #252a3a;
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
margin: -3px 17px 17px -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-divider {
|
||||
clear: both;
|
||||
padding: 10px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-view {
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.photo-info {
|
||||
padding: 0px 0px 0px 25px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
{% set title = "All photos" %}
|
||||
|
||||
{% include "page-top.html" %}
|
||||
|
||||
{% set locals = namespace() %}
|
||||
|
||||
|
||||
<div class="photo-feed">
|
||||
{% set locals.im_year = 0 %}
|
||||
{% for item in images %}
|
||||
{% if item.date.year != locals.im_year %}
|
||||
{% set locals.im_year = item.date.year %}
|
||||
<div class="feed-divider year"><h4>{{ locals.im_year }}</h4></div>
|
||||
{% endif %}
|
||||
<div class="photo">
|
||||
<a href="/photo/{{ item.uuid }}">
|
||||
<img src="thumb/set/feed/{{ item.uuid }}.jpg" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<br style="clear:both" />
|
||||
<a href="feed?page={{ page + 1 }}">Next</a>
|
||||
</div>
|
||||
|
||||
{% include "page-bottom.html" %}
|
|
@ -0,0 +1,24 @@
|
|||
{% set title = "Server statistics" %}
|
||||
|
||||
{% include "page-top.html" %}
|
||||
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>year</th>
|
||||
<th>month</th>
|
||||
<th>count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in images %}
|
||||
<tr>
|
||||
<td>{{row[1]}}</td>
|
||||
<td>{{row[2]}}</td>
|
||||
<td>{{row[0]}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include "page-bottom.html" %}
|
|
@ -0,0 +1,5 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% if title %}{{ title }} :: {% endif %}Photo App</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="layout" class="content pure-g">
|
||||
<div id="nav" class="pure-u">
|
||||
<a href="#" class="nav-menu-button">Menu</a>
|
||||
<div class="nav-inner">
|
||||
<div class="pure-menu">
|
||||
<ul class="pure-menu-list">
|
||||
<li class="pure-menu-item"><a href="/" class="pure-menu-link">All photos</a></li>
|
||||
<li class="pure-menu-item"><a href="/albums" class="pure-menu-link">Albums</a></li>
|
||||
<li class="pure-menu-item"><a href="/monthly" class="pure-menu-link">By month</a></li>
|
||||
<li class="pure-menu-item"><a href="/map" class="pure-menu-link">Map</a></li>
|
||||
<li class="pure-menu-item"><a href="/admin/trash" class="pure-menu-link">Trash</a></li>
|
||||
<li class="pure-menu-heading">Albums</li>
|
||||
<li class="pure-menu-item"><a href="/albums" class="pure-menu-link">Foo</a></li>
|
||||
<li class="pure-menu-item"><a href="/albums" class="pure-menu-link">Bar</a></li>
|
||||
<li class="pure-menu-heading">Tags</li>
|
||||
<li class="pure-menu-item"><a href="/tag/personal" class="pure-menu-link"><span class="email-label-personal"></span>Personal</a></li>
|
||||
<li class="pure-menu-item"><a href="/tag/work" class="pure-menu-link"><span class="email-label-work"></span>Work</a></li>
|
||||
<li class="pure-menu-item"><a href="/tag/travel" class="pure-menu-link"><span class="email-label-travel"></span>Travel</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="main" class="pure-u-1">
|
||||
<div class="email-content">
|
||||
<div class="email-content-header pure-g">
|
||||
<div class="pure-u-1-2">
|
||||
<h1 class="email-content-title">{{ title }}</h1>
|
||||
<p class="email-content-subtitle">
|
||||
The subheading
|
||||
</p>
|
||||
</div>
|
||||
<div class="email-content-controls pure-u-1-2">
|
||||
<button class="secondary-button pure-button">420</button>
|
||||
<button class="secondary-button pure-button">Blaze</button>
|
||||
<button class="secondary-button pure-button">It</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-content-body">
|
Loading…
Reference in New Issue