Compare commits

...

59 Commits

Author SHA1 Message Date
Markus Pawlata 37371fc962 compatibility fix for the fake iterator in ProcessWrapper 2019-07-14 02:08:57 +02:00
Markus Pawlata 6a0424b766 - seriously refactored so imports hurt less
- changed imports to python3
2019-07-14 01:20:35 +02:00
Markus Pawlata d10da55654 Updated readme, changes to sample to match, some smaller fixes 2018-12-10 23:51:38 +01:00
Markus Pawlata f79eda5ec6 Small fix for video timestamps, refactored PhotoaAlbum.py & added reference for xwipe 2018-12-08 20:57:41 +01:00
Markus Pawlata a648b931bb Changed powered by line 2018-12-07 01:43:25 +01:00
Markus Pawlata d9a96b827f Added swiping on mobile, fixed index.html, small changes to css 2018-12-07 01:35:40 +01:00
Markus Pawlata 4961c398a5 fixed error in refactoring, changed auth-system to multi token, cleanup after major change 2018-12-05 01:10:40 +01:00
Markus Pawlata 48bf325d77 pep8'd what I could, including tabs to spaces 2018-11-30 01:28:24 +01:00
Markus Pawlata a45ecc76b0 Added requirements.txt for a possible virtualenv 2018-11-30 00:26:38 +01:00
Markus Pawlata 23900670ca Restored local js/css processing, removed superfluous .gitignore files, removed htacces-files, moved sample config to sample files 2018-11-30 00:12:11 +01:00
Joachim Tingvold 3b9f64b057 Fixed logical error after introducing timestamp of videos from
EXIF data. Previous logic dictated that all timestamps should
live within attribute name starting with "dateTime", which caused
issues when re-scanning an album (complaining about corrupt cache).
Renamed variable and all is good.
2018-11-15 01:40:22 +01:00
Joachim Tingvold 6029f92aad Scale videos properly. Previously there was an issue with vertical
or squared videos bigger than 720 pixels high. Also sets timestamp
for videoCreateDate even if nothing is found. Done to avoid cache
corruption when walking the albums.
2018-11-11 05:54:57 +01:00
Joachim Tingvold 5cbc3f31e3 Use other timestamp for videos.
Easier to manipulate timestamp via exiftool this way.
2018-11-11 04:22:15 +01:00
Jason A. Donenfeld 1cc1ec02aa AJAX Crawl Specification is depreciated
See [1] for more information.

[1] http://googlewebmastercentral.blogspot.fr/2015/10/deprecating-our-ajax-crawling-scheme.html
2015-11-10 21:16:09 +01:00
Jason A. Donenfeld 1a2b14bb47 More python insanity 2015-11-10 21:15:25 +01:00
Joachim Tingvold e4244d086d Fix issue with duplicate video frames.
Whenever you had more than one video in a row, it would not
clear the old video before showing the next, resulting in multiple
video frames being shown, only getting cleared whenever a photo
appeared. Big irritation, small fix (-:
2015-10-20 03:20:47 +02:00
Joachim Tingvold 05475b6ed0 Fix issue with transcoding old MJPEG-movies. 2015-07-07 14:33:16 +02:00
Joachim Tingvold 0e258a46dc Don't crash when unable to unlink 2015-06-17 20:41:38 +02:00
Joachim Tingvold 49b4667703 Added comment. 2015-06-17 20:35:14 +02:00
Joachim Tingvold 9a15b2c2c0 Be totally quiet. Overwrite thumbs. 2015-06-17 20:10:50 +02:00
Joachim Tingvold 52e025b07d Use H264 (rather than webm)
Primarily done to support IE and Safari.
2015-06-17 17:30:35 +02:00
Joachim Tingvold fa5f0c1fcd Re-create thumbs/transcodes if deleted.
Previously thumbs/transcodes were only created if mtime() of
the file didn't match what was stored in the json-file. If you
deleted a thumb/transcode from the cache_path, it would not be
re-created unless you modify the mtime() of the original files
(in album_path).
2015-06-17 14:49:55 +02:00
Joachim Tingvold ec0828e610 Merge updates from 'master' 2015-06-06 06:55:18 +02:00
Joachim Tingvold 37b63f2a4a Kill pool if interrupted/error. 2015-06-06 06:45:22 +02:00
Joachim Tingvold a169bafdd8 Fix args passed to videotool. Fixed video.
Encodes at 4Mbps with multiple cores.
2015-06-02 23:13:26 +02:00
Joachim Tingvold 3bdcd1cc66 Handle TypeError. exif{}-date is already datetime. 2015-06-02 20:28:28 +02:00
Joachim Tingvold 3aa0e4afb0 Updated jQuery Mousewheel so it actually works :-D 2015-05-06 00:21:54 +02:00
Joachim Tingvold b97981aee8 Cleanup unused files. Minor tweaks. 2015-05-05 12:48:19 +02:00
Joachim Tingvold 37db1402c5 Minor changes. 2015-05-05 10:08:51 +02:00
Joachim Tingvold ce45ae55ef Minify-scripts. Updated jQuery. 2015-05-05 09:57:16 +02:00
Joachim Tingvold 1b2cde7661 Correct order of arguments. 2015-05-05 00:39:26 +02:00
Joachim Tingvold 50ab2143ec Heh :-D 2015-05-05 00:12:40 +02:00
Joachim Tingvold ff7e540eb8 Thumbnail-method for both photo and video. 2015-05-05 00:08:37 +02:00
Joachim Tingvold 80bb5258d6 If tool fails, it would not continue. 2015-05-04 23:57:27 +02:00
Joachim Tingvold ac52f76025 First attempt at merging video-patch. 2015-05-04 19:46:58 +02:00
Joachim Tingvold 6e4fe49d50 Updated master, merging into patches. 2015-05-04 17:45:03 +02:00
Jerome Charaoui 5d8aa9b212 Add wishlist item : download link for original video 2014-08-16 11:41:45 -04:00
Jerome Charaoui d5c9f54b34 Convert Exif dateTime[Original] attributes to datetime type to fix comparison with dateTimeFile 2014-08-15 20:46:23 -04:00
Joachim Tingvold a7192297df Indents (-: 2014-03-16 15:19:52 +01:00
Joachim Tingvold dee0ab294f Use threading to make thumbs in parallel.
Known bug: interrupt makes it go crazy.
2014-03-16 15:17:13 +01:00
Joachim Tingvold c9ce27eb87 Add 1600px size. Remove unused 800px size. 2014-03-16 15:03:54 +01:00
Joachim Tingvold 20fe79eb4e Show image before album. Loads image faster when using direct image URL. 2014-03-16 15:02:03 +01:00
Jerome Charaoui 52667388e9 Fix caching of top-level items 2014-02-13 23:28:59 -05:00
Antoine Beaupré ecbd6d5552 fix check for webm support, which had logic reversed 2014-02-13 21:32:45 -05:00
Jerome Charaoui 5e53d3ac3b Close bug ea1/31e (Libav support) 2014-01-30 14:37:39 -05:00
Jerome Charaoui 213fd2ffc7 Support libav for video processing 2014-01-29 18:11:25 -05:00
Jerome Charaoui 471e0eb820 Use Modernizr to check for <video> support and show an error messag if absent. 2014-01-28 01:18:30 -05:00
Jerome Charaoui 80060b37c1 Create <video> element dynamically 2014-01-27 23:02:10 -05:00
Jerome Charaoui 1bb130d37b Fix testing for duration in video metadata 2013-12-22 21:40:48 -05:00
Jerome Charaoui 2df2730cd3 Read metadata from previously transcoded video (must account for rotation and/or downscaling) 2013-12-22 21:09:05 -05:00
Jerome Charaoui 0d35832b21 Handle video metadata correctly 2013-12-22 20:33:43 -05:00
Jerome Charaoui 50a2284d50 Close bug ea1/9da (cropped video) 2013-12-22 03:07:21 -05:00
Jerome Charaoui 993b799f62 Implement <link rel="video_src"> tag 2013-12-22 03:04:30 -05:00
Jerome Charaoui 10a31bbd1e Fix thumb scrolling on videos 2013-12-22 03:01:50 -05:00
Jerome Charaoui 315b03f737 Add automatic scaling for video 2013-12-22 02:28:34 -05:00
Jerome Charaoui 1409c13e30 Overlay a small icon on video thumbnails 2013-12-21 23:50:47 -05:00
Jerome Charaoui 0443b99492 If thumbing or transcoding fails, invalidate entry. 2013-12-21 20:46:56 -05:00
Jerome Charaoui aa55758aab Initialize bugs-everywhere and add a few bugs 2013-12-21 15:01:08 -05:00
Jerome Charaoui 6f482f8f78 Add support for videos using ffmpeg for transcoding to webm format and HTML5 video tag. 2013-12-21 00:42:23 -05:00
30 changed files with 9850 additions and 6686 deletions

7
.gitignore vendored
View File

@ -1 +1,8 @@
*.swp
deployment-config.mk
scanner/venv
scanner/floatapp/app.cfg
*.pyc
*.min.css
*.min.js
.vscode

228
README.md
View File

@ -1,48 +1,61 @@
# PhotoFloat
### A Web 2.0 Photo Gallery Done Right via Static JSON & Dynamic Javascript
#### by Jason A. Donenfeld (<Jason@zx2c4.com>)
# subPhotoFloat A [Photofloat](https://git.zx2c4.com/PhotoFloat/) fork
## Web Photo Gallery via Static JSON & Dynamic Javascript with a Python Backend
![Screenshot](http://data.zx2c4.com/photo-float-small.jpg)
by Jason A. Donenfeld (<Jason@zx2c4.com>)
with some changes by Markus Pawlata (<markus@derdritte.net>) and other [collaborators](https://git.jocke.no/photofloat/log/?h=patches).
PhotoFloat is an open source web photo gallery aimed at sleekness and speed. It keeps with an old hat mentality, preferring to work over directory structures rather than esoteric photo database management software. Everything it generates is static, which means it's extremely fast.
![Screenshot](https://photos.derdritte.net/img/screenshot.png)
[Check out a demo!](http://photos.jasondonenfeld.com/#santa_fe_and_telluride_8.19.10-8.27.10/western_202.jpg)
subPhotoFloat is an open source web photo gallery aimed at sleekness and speed. It keeps with a minimalist philosophy, preferring to work over directory structures rather than bloated photo database management software (there are good options available for that, if you want it). Everything it generates is static, which means it's extremely fast.
[Check out a demo!](https://photos.derdritte.net/#!/random)
## How It Works
PhotoFloat consists of two segments a Python script and a JavaScript application.
subPhotoFloat consists of two main segments a Python script and a JavaScript application.
The Python script scans a directory tree of images, whereby each directory constitutes an album. It then populates a second folder, known as the cache folder with statically generated JSON files and thumbnails. The scanner extracts metadata from EXIF tags in JPEG photos. PhotoFloat is smart about file and directory modification time, so you are free to run the scanner script as many times as you want, and it will be very fast if there are few or zero changes since the last time you ran it.
The Python script scans a directory tree of images, whereby each directory constitutes an album. It then populates a second folder, known as the cache folder with statically generated JSON files and thumbnails. The scanner extracts metadata from EXIF tags in JPEG photos. subPhotoFloat is smart about file and directory modification time, so you are free to run the scanner script as many times as you want, and it will be very fast if there are few or zero changes since the last time you ran it.
Also a part of the Python script is a small flask webapp which allows you to have authentication for certain albums/images and can start the scanner.
The JavaScript application consists of a single `index.html` file with a single `scripts.min.js` and a single `styles.min.css`. It fetches the statically generated JSON files and thumbnails on the fly from the `cache` folder to create a speedy interface. Features include:
* Animations to make the interface feel nice
* Separate album view and photo view
* Album metadata pre-fetching
* Photo pre-loading
* Recursive async randomized tree walking album thumbnail algorithm
* Smooth up and down scaling
* Mouse-wheel support
* Swipe support (on mobile)
* Metadata display
* Consistant hash url format
* Consistent hash url format
* Linkable states via ajax urls
* Static rendering for googlebot conforming to the AJAX crawling spec.
* Facebook meta tags for thumbnail and post type
* Link to original images (can be turned off)
* Optional Google Analytics integration
* Optional server-side authentication support
* A thousand other tweaks here and there...
It is, essentially, the slickest and fastest, most minimal but still well-featured photo gallery app on the net.
It is, essentially, a very slick and fast, fairly minimal but still well-featured photo gallery app.
## Dependencies
* python >= 2.6
* pillow >= 5.3.0
* nginx (or any webserver, really)
### Optional
* flask >= 0.11 (for authentication)
* flask-login >= 0.4.1 (for authentication)
* virtualenv (this is nice, [believe me](https://docs.python-guide.org/dev/virtualenvs/#lower-level-virtualenv))
* ffmpeg (for video conversion)
## Installation
#### Download the source code from the git repository:
### Download the source code from the git repository
$ git clone git://git.zx2c4.com/PhotoFloat
$ cd PhotoFloat
$ git clone https://derdritte.net/gitea/markus/photofloat
$ cd photofloat
#### Change or delete the Google Analytics ID tracker:
### Change or delete the Google Analytics ID tracker
To delete:
@ -54,18 +67,18 @@ To change:
Modify the part that says UA-XXXXXX-X and put your own in there.
#### Tweak the index.html page to have a custom title or copyright notice.
### Tweak the index.html page to have a custom title or copyright notice
$ vim web/index.html
#### Build the web page.
### Build the web page
This simply runs all the javascript through Google Closure Compiler and all the CSS through YUI Compressor to minify and concatenate everything. Be sure you have java installed.
$ cd web
$ make
#### Generate the albums:
### Generate the albums
Now that we're in the web directory, let's make a folder for cache and a folder for the pictures:
@ -79,108 +92,149 @@ When you're done, fill albums with photos and directories of photos. You can als
After it finishes, you will be all set. Simply have your web server serve pages out of your web directory. You may want to do the scanning step in a cronjob, if you don't use the deployment makefiles mentioned below.
## Optional: Server-side Authentication
### Nginx configuration for static-only
Please keep in mind this will not provide any kind of access-restrictions.
server {
listen 80;
server_name photos.jasondonenfeld.com;
location / {
index index.html;
root /var/www/htdocs/photos.jasondonenfeld.com;
}
}
Now after deploying `/var/www/htdocs/photos.jasondonenfeld.com` should contain:
* index.html
* js/
* css/
* img/
* albums/
* cache/
You can easily manage that by creating a folder structure and copying relevant files over:
$ cd ..
photofloat ~ $ mkdir <deployment-folder>/js <deployment-folder>/css
$ cp web/js/scripts.min.js <deployment-folder>/js
$ cp web/css/styles.min.css <deployment-folder>/css
$ cp -a fonts img index.html <deployment-folder>/
For easy updates `albums` and `cache` can be set to also live in \<deployment-folder>, this is especially recommended if you are using the optional flask app mentioned in the following section.
Do **not** keep any of your config or python-files where the webserver can read or write to, the `deployment-config.mk` is most sensitive. If you only want the static html/json & javascript application you are done now!
## Optional: Server-side Authentication using flask
The JavaScript application uses a very simple API to determine if a photo can be viewed or not. If a JSON file returns error `403`, the album is hidden from view. To authenticate, `POST` a username and a password to `/auth`. If unsuccessful, `403` is returned. If successful, `200` is returned, and the previously denied json files may now be requested. If an unauthorized album is directly requested in a URL when the page loads, an authentication box is shown.
PhotoFloat ships with an optional server side component called FloatApp to faciliate this, which lives in `scanner/floatapp`. It is a simple Flask-based Python web application.
subPhotoFloat ships with an optional server side component called FloatApp to facilitate this, which lives in `scanner/floatapp`. It is a simple Flask-based Python web application.
#### Edit the app.cfg configuration file:
### Installation
$ cd scanner/floatapp
$ vim app.cfg
We need to install flask and other dependencies, ideally in a virtualenv, as this will keep your system-wide python installation clean and you can easily install more packages or different versions.
Give this file a correct username and password, for both an admin user and a photo user, as well as a secret token. The admin user is allowed to call `/scan`, which automatically runs the scanner script mentioned in the previous section.
$ cd scanner
$ virtualenv venv
$ source venv/bin/activate
$ pip install -r requirements.txt
#### Decide which albums or photos are protected:
### Edit the app.cfg configuration file
$ vim auth.txt
$ vim floatapp/app.cfg
This file takes one path per line. It restricts access to all photos in this path. If the path is a single photo, then that single photo is restricted.
Give this file a correct username and password for an admin, as well as a secret token. The admin user is allowed to call `/scan`, which automatically runs the scanner script mentioned in the previous section.
#### Configure nginx:
In `app.cfg` you may also add elements to the `PERMISSION_MAP` as follows. The dictionary takes any path (to either an album or an image) and restricts any album or image matching that path to the listed tokens.
FloatApp makes use of `X-Accel-Buffering` and `X-Accel-Redirect` to force the server-side component to have minimal overhead. Here is an example nginx configuration that can be tweaked:
PERMISSION_MAP = {
'private': ['thisisatoken'],
'alsoprivate/butonlythis.jpg': ['morethan', 'onetoken'],
}
server {
listen 80;
server_name photos.jasondonenfeld.com;
location / {
index index.html;
root /var/www/htdocs/photos.jasondonenfeld.com;
}
include uwsgi_params;
location /albums/ {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /cache/ {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /scan {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /auth {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /photos {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /internal-cache/ {
internal;
alias /var/www/uwsgi/photofloat/cache/;
}
location /internal-albums/ {
internal;
alias /var/www/uwsgi/photofloat/albums/;
}
Tokens can contain anything you wish and you can add as many paths and tokes as you require. One match in the `PERMISSION_MAP` will allow access even if another rule would forbid it. The admin is allowed to see any album or image.
### Configure nginx
FloatApp makes use of `X-Accel-Buffering` and [X-Accel-Redirect](https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/) to force the server-side component to have minimal overhead when serving images via flask. Here is an example nginx configuration that can be tweaked:
server {
listen 80;
server_name photos.jasondonenfeld.com;
location / {
index index.html;
root /var/www/htdocs/photos.jasondonenfeld.com;
}
include uwsgi_params;
location /albums/ {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /cache/ {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /scan {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /auth {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /photos {
uwsgi_pass unix:/var/run/uwsgi-apps/photofloat.socket;
}
location /internal-cache/ {
internal;
alias /var/www/uwsgi/photofloat/cache/;
}
location /internal-albums/ {
internal;
alias /var/www/uwsgi/photofloat/albums/;
}
}
Note that the `internal-*` paths must match that of `app.cfg`. This makes use of uwsgi for execution:
Note that the `internal-*` paths must match that of `app.cfg`, since the flask app will redirect the "external" `/albums` and `/cache` paths to internal ones set in that config.
metheny ~ # cat /etc/uwsgi.d/photofloat.ini
### Configure uwsgi (or use an alternate wsgi-provider)
$ cat /etc/uwsgi.d/photofloat.ini
[uwsgi]
chdir = /var/www/uwsgi/%n
master = true
uid = %n
gid = %n
chmod-socket = 660
chown-socket = %n:nginx
chown-socket = nginx:nginx
socket = /var/run/uwsgi-apps/%n.socket
logto = /var/log/uwsgi/%n.log
virtualenv = /var/www/uwsgi/photofloat/scanner/venv
processes = 4
idle = 1800
die-on-idle = true
plugins = python27
module = floatapp:app
## Optional: Server-side Rendering
Some webpages may desire to optionally render pages server side when special query strings are attached, so that GoogleBot may index pages. PhotoFloat supports the [AJAX crawl specification](https://developers.google.com/webmasters/ajax-crawling/).
location / {
location = / {
include uwsgi_params;
uwsgi_param HTTP_X_SE_ORIGINAL_URL $scheme://$host$request_uri;
if ($args ~* _escaped_fragment_=) {
uwsgi_pass unix:/var/run/uwsgi-apps/server-execute-phantom.socket;
}
}
index index.html;
root /var/www/htdocs/photos.jasondonenfeld.com;
}
This makes use of the [Server Execute Phantom project](http://git.zx2c4.com/server-execute-phantom/about/).
Change the paths for chdir, socket, logto and virtualenv to your preference.
Naturally, you can use any of the options available to [deploy](http://flask.pocoo.org/docs/1.0/deploying/#self-hosted-options) the flask app.
## Optional: Deployment Makefiles
Both the scanner and the webpage have a `make deploy` target, and the scanner has a `make scan` target, to automatically deploy assets to a remote server and run the scanner. For use, customize `deployment-config.mk` in the root of the project, and carefully read the `Makefile`s to learn what's happening.
Be aware: you will very likely have to adapt the deploy-instructions to what you have deployed on your server.
If you are using the flask-app you will most likely not need the Makefiles.
## Mailing List & Suggestions
If you have any suggestions, feel free to contact the PhotoFloat community via [our mailing list](http://lists.zx2c4.com/mailman/listinfo/photofloat). We're open to adding all sorts of features and working on integration points with other pieces of software.
If you have any suggestions, feel free to contact the subPhotoFloat community via [our mailing list](http://lists.zx2c4.com/mailman/listinfo/photofloat). We're open to adding all sorts of features and working on integration points with other pieces of software.
Note: As the project is 8+ years old, the mailing list has slowed down a bit, if you do not get an answer immediately, please be patient and give other users some time to respond.
This app is also fairly small, so this might be the perfect project to try and add some small features yourself. For reference you may want to look at the flask & nginx documentation.
## License

1
scanner/.gitignore vendored
View File

@ -1 +0,0 @@
*.pyc

View File

@ -1,45 +1,90 @@
import os.path
from datetime import datetime
def message(category, text):
if message.level <= 0:
sep = " "
else:
sep = "--"
print "%s %s%s[%s]%s%s" % (datetime.now().isoformat(), max(0, message.level) * " |", sep, category, max(1, (14 - len(category))) * " ", text)
if message.level <= 0:
sep = " "
else:
sep = "--"
print("%s %s%s[%s]%s%s" % (
datetime.now().isoformat(),
max(0, message.level) * " |",
sep,
category,
max(1, (14 - len(category))) * " ",
text))
message.level = -1
def next_level():
message.level += 1
message.level += 1
def back_level():
message.level -= 1
message.level -= 1
def set_cache_path_base(base):
trim_base.base = base
trim_base.base = base
def untrim_base(path):
return os.path.join(trim_base.base, path)
return os.path.join(trim_base.base, path)
def trim_base_custom(path, base):
if path.startswith(base):
path = path[len(base):]
if path.startswith('/'):
path = path[1:]
return path
if path.startswith(base):
path = path[len(base):]
if path.startswith('/'):
path = path[1:]
return path
def trim_base(path):
return trim_base_custom(path, trim_base.base)
def cache_base(path):
path = trim_base(path).replace('/', '-').replace(' ', '_').replace('(', '').replace('&', '').replace(',', '').replace(')', '').replace('#', '').replace('[', '').replace(']', '').replace('"', '').replace("'", '').replace('_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
if len(path) == 0:
path = "root"
return path
return trim_base_custom(path, trim_base.base)
def cache_base(path, filepath=False):
if len(path) == 0:
return "root"
elif filepath and len(path.split(os.sep)) < 2:
path = "root-" + path
path = trim_base(path).replace(
'/', '-').replace(
' ', '_').replace(
'(', '').replace(
'&', '').replace(
',', '').replace(
')', '').replace(
'#', '').replace(
'[', '').replace(
']', '').replace(
'"', '').replace(
"'", '').replace(
'_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
return path
def json_cache(path):
return cache_base(path) + ".json"
return cache_base(path) + ".json"
def image_cache(path, size, square=False):
if square:
suffix = str(size) + "s"
else:
suffix = str(size)
return cache_base(path) + "_" + suffix + ".jpg"
if square:
suffix = str(size) + "s"
else:
suffix = str(size)
return cache_base(path, True) + "_" + suffix + ".jpg"
def video_cache(path):
return cache_base(path, True) + ".mp4"
def file_mtime(path):
return datetime.fromtimestamp(int(os.path.getmtime(path)))
return datetime.fromtimestamp(int(os.path.getmtime(path)))

File diff suppressed because it is too large Load Diff

View File

@ -6,113 +6,143 @@ from PhotoAlbum import Photo, Album, PhotoAlbumEncoder
from CachePath import *
import json
class TreeWalker:
def __init__(self, album_path, cache_path):
self.album_path = os.path.abspath(album_path).decode(sys.getfilesystemencoding())
self.cache_path = os.path.abspath(cache_path).decode(sys.getfilesystemencoding())
set_cache_path_base(self.album_path)
self.all_albums = list()
self.all_photos = list()
self.walk(self.album_path)
self.big_lists()
self.remove_stale()
message("complete", "")
def walk(self, path):
next_level()
if not os.access(path, os.R_OK | os.X_OK):
message("access denied", os.path.basename(path))
back_level()
return None
message("walking", os.path.basename(path))
cache = os.path.join(self.cache_path, json_cache(path))
cached = False
cached_album = None
if os.path.exists(cache):
try:
cached_album = Album.from_cache(cache)
if file_mtime(path) <= file_mtime(cache):
message("full cache", os.path.basename(path))
cached = True
album = cached_album
for photo in album.photos:
self.all_photos.append(photo)
else:
message("partial cache", os.path.basename(path))
except KeyboardInterrupt:
raise
except:
message("corrupt cache", os.path.basename(path))
cached_album = None
if not cached:
album = Album(path)
for entry in os.listdir(path):
if entry[0] == '.':
continue
try:
entry = entry.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
next_level()
message("unicode error", entry.decode(sys.getfilesystemencoding(), "replace"))
back_level()
continue
entry = os.path.join(path, entry)
if os.path.isdir(entry):
next_walked_album = self.walk(entry)
if next_walked_album is not None:
album.add_album(next_walked_album)
elif not cached and os.path.isfile(entry):
next_level()
cache_hit = False
if cached_album:
cached_photo = cached_album.photo_from_path(entry)
if cached_photo and file_mtime(entry) <= cached_photo.attributes["dateTimeFile"]:
message("cache hit", os.path.basename(entry))
cache_hit = True
photo = cached_photo
if not cache_hit:
message("metainfo", os.path.basename(entry))
photo = Photo(entry, self.cache_path)
if photo.is_valid:
self.all_photos.append(photo)
album.add_photo(photo)
else:
message("unreadable", os.path.basename(entry))
back_level()
if not album.empty:
message("caching", os.path.basename(path))
album.cache(self.cache_path)
self.all_albums.append(album)
else:
message("empty", os.path.basename(path))
back_level()
return album
def big_lists(self):
photo_list = []
self.all_photos.sort()
for photo in self.all_photos:
photo_list.append(photo.path)
message("caching", "all photos path list")
fp = open(os.path.join(self.cache_path, "all_photos.json"), 'w')
json.dump(photo_list, fp, cls=PhotoAlbumEncoder)
fp.close()
def remove_stale(self):
message("cleanup", "building stale list")
all_cache_entries = { "all_photos.json": True, "latest_photos.json": True }
for album in self.all_albums:
all_cache_entries[album.cache_path] = True
for photo in self.all_photos:
for entry in photo.image_caches:
all_cache_entries[entry] = True
message("cleanup", "searching for stale cache entries")
for cache in os.listdir(self.cache_path):
try:
cache = cache.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
pass
if cache not in all_cache_entries:
message("cleanup", os.path.basename(cache))
os.unlink(os.path.join(self.cache_path, cache))
def __init__(self, album_path, cache_path):
self.album_path = os.path.abspath(
album_path).decode(sys.getfilesystemencoding())
self.cache_path = os.path.abspath(
cache_path).decode(sys.getfilesystemencoding())
set_cache_path_base(self.album_path)
self.all_albums = list()
self.all_photos = list()
self.walk(self.album_path)
self.big_lists()
self.remove_stale()
message("complete", "")
def walk(self, path):
next_level()
if not os.access(path, os.R_OK | os.X_OK):
message("access denied", os.path.basename(path))
back_level()
return None
message("walking", os.path.basename(path))
cache = os.path.join(self.cache_path, json_cache(path))
cached = False
cached_album = None
if os.path.exists(cache):
try:
cached_album = Album.from_cache(cache)
if file_mtime(path) <= file_mtime(cache):
message("full cache", os.path.basename(path))
cached = True
album = cached_album
for photo in album.photos:
self.all_photos.append(photo)
else:
message("partial cache", os.path.basename(path))
except KeyboardInterrupt:
raise
except:
message("corrupt cache", os.path.basename(path))
cached_album = None
if not cached:
album = Album(path)
for entry in os.listdir(path):
if entry[0] == '.':
continue
try:
entry = entry.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
next_level()
message("unicode error", entry.decode(
sys.getfilesystemencoding(), "replace"))
back_level()
continue
entry = os.path.join(path, entry)
if os.path.isdir(entry):
next_walked_album = self.walk(entry)
if next_walked_album is not None:
album.add_album(next_walked_album)
elif not cached and os.path.isfile(entry):
next_level()
cache_hit = False
if cached_album:
cached_photo = cached_album.photo_from_path(entry)
if (cached_photo and file_mtime(
entry) <= cached_photo.attributes["dateTimeFile"]):
cache_file = None
if "mediaType" in cached_photo.attributes:
if cached_photo.attributes["mediaType"] == "video":
# if video
cache_file = os.path.join(
self.cache_path, video_cache(entry))
else:
# if image
cache_file = os.path.join(
self.cache_path,
image_cache(entry, 1024, False))
else:
# if image
cache_file = os.path.join(
self.cache_path,
image_cache(entry, 1024, False))
# at this point we have full path to cache image/video
# check if it actually exists
if os.path.exists(cache_file):
message("cache hit", os.path.basename(entry))
cache_hit = True
photo = cached_photo
if not cache_hit:
message("metainfo", os.path.basename(entry))
photo = Photo(entry, self.cache_path)
if photo.is_valid:
self.all_photos.append(photo)
album.add_photo(photo)
else:
message("unreadable", os.path.basename(entry))
back_level()
if not album.empty:
message("caching", os.path.basename(path))
album.cache(self.cache_path)
self.all_albums.append(album)
else:
message("empty", os.path.basename(path))
back_level()
return album
def big_lists(self):
photo_list = []
self.all_photos.sort()
for photo in self.all_photos:
photo_list.append(photo.path)
message("caching", "all photos path list")
fp = open(os.path.join(self.cache_path, "all_photos.json"), 'w')
json.dump(photo_list, fp, cls=PhotoAlbumEncoder)
fp.close()
def remove_stale(self):
message("cleanup", "building stale list")
all_cache_entries = {"all_photos.json": True,
"latest_photos.json": True}
for album in self.all_albums:
all_cache_entries[album.cache_path] = True
for photo in self.all_photos:
for entry in photo.image_caches:
all_cache_entries[entry] = True
message("cleanup", "searching for stale cache entries")
for cache in os.listdir(self.cache_path):
try:
cache = cache.decode(sys.getfilesystemencoding())
except KeyboardInterrupt:
raise
except:
pass
if cache not in all_cache_entries:
message("cleanup", os.path.basename(cache))
os.unlink(os.path.join(self.cache_path, cache))

View File

@ -0,0 +1,50 @@
from CachePath import message
import os
import subprocess
class VideoToolWrapper(object):
def call(self, *args):
path = args[-1]
for tool in self.wrappers:
try:
if self.check_output:
p = subprocess.check_output((tool,) + args)
else:
p = subprocess.call((tool,) + args)
if p > 0:
return False
else:
return "SUCCESS"
except KeyboardInterrupt:
if self.cleanup:
self.remove(path)
raise
except OSError:
continue
except:
if self.cleanup:
self.remove(path)
continue
return p
return False
def remove(self, path):
try:
os.unlink(path)
except:
pass
class VideoTranscodeWrapper(VideoToolWrapper):
def __init__(self):
self.wrappers = ['avconv', 'ffmpeg']
self.check_output = False
self.cleanup = True
class VideoProbeWrapper(VideoToolWrapper):
def __init__(self):
self.wrappers = ['avprobe', 'ffprobe']
self.check_output = True
self.cleanup = False

View File

@ -1,10 +1,176 @@
from flask import Flask
from flask_login import LoginManager
import os.path
import os
from TreeWalker import TreeWalker
from functools import wraps
from mimetypes import guess_type
from random import shuffle
from flask import Flask, Response, abort, json, jsonify, request
from flask_login import current_user, login_user, logout_user
from .process import send_process
from .jsonp import jsonp
from .login import admin_user, load_user
from .login import login_manager
app = Flask(__name__)
app.config.from_pyfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.cfg"))
login_manager = LoginManager()
import login
app.config.from_pyfile(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "app.cfg"))
login_manager.setup_app(app)
import endpoints
cwd = os.path.dirname(os.path.abspath(__file__))
permission_map = app.config.get('PERMISSION_MAP', [])
def accel_redirect(internal, real, relative_name):
real_path = os.path.join(real, relative_name)
internal_path = os.path.join(internal, relative_name)
if not os.path.isfile(real_path):
abort(404)
mimetype = None
types = guess_type(real_path)
if len(types) != 0:
mimetype = types[0]
response = Response(mimetype=mimetype)
response.headers.add("X-Accel-Redirect", internal_path)
response.cache_control.public = True
if mimetype == "application/json":
response.cache_control.max_age = 3600
else:
response.cache_control.max_age = 29030400
return response
def cache_base(path):
path = path.replace(
'/', '-').replace(
' ', '_').replace(
'(', '').replace(
'&', '').replace(
',', '').replace(
')', '').replace(
'#', '').replace(
'[', '').replace(
']', '').replace(
'"', '').replace(
"'", '').replace(
'_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
if len(path) == 0:
path = "root"
return path
def has_permission(path):
if not current_user.is_anonymous and current_user.is_admin:
return True
for auth_path in permission_map.keys():
# this is a protected object
if (path.startswith(auth_path) or
path.startswith(cache_base(auth_path))):
if current_user.is_anonymous:
return False
if current_user.id in permission_map.get(auth_path, []):
return True
else:
return False
return True
def admin_required(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if (query_is_admin_user(request.args) or
(current_user.is_authenticated and current_user.admin)):
return fn(*args, **kwargs)
return app.login_manager.unauthorized()
return decorated_view
def query_is_admin_user(query):
username = query.get("username", None)
password = query.get("password", None)
return (username == app.config["ADMIN_USERNAME"] and
password == app.config["ADMIN_PASSWORD"])
@app.route("/scan")
@admin_required
def scan_photos():
global cwd
response = send_process([
"stdbuf",
"-oL",
os.path.abspath(os.path.join(cwd, "../main.py")),
os.path.abspath(app.config["ALBUM_PATH"]),
os.path.abspath(app.config["CACHE_PATH"])
],
os.path.join(cwd, "scanner.pid"))
response.headers.add("X-Accel-Buffering", "no")
response.cache_control.no_cache = True
return response
@app.route("/auth")
def login():
if 'logout' in request.args:
logout_user()
if current_user.is_authenticated:
logout_user()
if (query_is_admin_user(request.form) or
query_is_admin_user(request.args)):
login_user(admin_user, remember=True)
else:
user_id = (request.form.get('username') or
request.args.get('username', None))
if user_id:
login_user(load_user(user_id), remember=True)
return 'You are now logged in.'
return ""
@app.route("/albums/<path:path>")
def albums(path):
if not has_permission(path):
abort(403)
return accel_redirect(
app.config["ALBUM_ACCEL"], app.config["ALBUM_PATH"], path)
@app.route("/cache/<path:path>")
def cache(path):
if not has_permission(path):
abort(403)
return accel_redirect(
app.config["CACHE_ACCEL"], app.config["CACHE_PATH"], path)
@app.route("/photos")
@jsonp
def photos():
f = open(os.path.join(app.config["CACHE_PATH"], "all_photos.json"), "r")
photos = json.load(f)
f.close()
photos = [photo for photo in photos if has_permission(photo)]
count = int(request.args.get("count", len(photos)))
random = request.args.get("random") == "true"
if random:
shuffle(photos)
else:
photos.reverse()
response = jsonify(photos=photos[0:count])
response.cache_control.no_cache = True
return response

View File

@ -1,8 +1,9 @@
ADMIN_USERNAME = "misterscanner"
ADMIN_PASSWORD = "ilovescanning"
PHOTO_USERNAME = "photos" # The GUI currently hardcodes 'photos', so don't change this
PHOTO_PASSWORD = "myphotopassword"
PERMISSION_MAP = {
'album/or/image/path': ['token']
}
ALBUM_PATH = "/var/www/uwsgi/photofloat/albums"
ALBUM_ACCEL = "/internal-albums"

View File

@ -1 +0,0 @@
path/to/some/place

View File

@ -1,118 +0,0 @@
from floatapp import app
from floatapp.login import admin_required, login_required, is_authenticated, query_is_photo_user, query_is_admin_user, photo_user, admin_user
from floatapp.jsonp import jsonp
from process import send_process
from TreeWalker import TreeWalker
from flask import Response, abort, json, request, jsonify
from flask_login import login_user, current_user
from random import shuffle
import os
from mimetypes import guess_type
cwd = os.path.dirname(os.path.abspath(__file__))
@app.route("/scan")
@admin_required
def scan_photos():
global cwd
response = send_process([ "stdbuf", "-oL", os.path.abspath(os.path.join(cwd, "../main.py")),
os.path.abspath(app.config["ALBUM_PATH"]), os.path.abspath(app.config["CACHE_PATH"]) ],
os.path.join(cwd, "scanner.pid"))
response.headers.add("X-Accel-Buffering", "no")
response.cache_control.no_cache = True
return response
@app.route("/auth")
def login():
success = False
if current_user.is_authenticated():
success = True
elif query_is_photo_user(request.form) or query_is_photo_user(request.args):
success = login_user(photo_user, remember=True)
elif query_is_admin_user(request.form) or query_is_admin_user(request.args):
success = login_user(admin_user, remember=True)
if not success:
abort(403)
return ""
def cache_base(path):
path = path.replace('/', '-').replace(' ', '_').replace('(', '').replace('&', '').replace(',', '').replace(')', '').replace('#', '').replace('[', '').replace(']', '').replace('"', '').replace("'", '').replace('_-_', '-').lower()
while path.find("--") != -1:
path = path.replace("--", "-")
while path.find("__") != -1:
path = path.replace("__", "_")
if len(path) == 0:
path = "root"
return path
auth_list = [ ]
def read_auth_list():
global auth_list, cwd
f = open(os.path.join(cwd, "auth.txt"), "r")
paths = [ ]
for path in f:
path = path.strip()
paths.append(path)
paths.append(cache_base(path))
f.close()
auth_list = paths
# TODO: Make this run via inotify
read_auth_list()
def check_permissions(path):
if not is_authenticated():
for auth_path in auth_list:
if path.startswith(auth_path):
abort(403)
@app.route("/albums/<path:path>")
def albums(path):
check_permissions(path)
return accel_redirect(app.config["ALBUM_ACCEL"], app.config["ALBUM_PATH"], path)
@app.route("/cache/<path:path>")
def cache(path):
check_permissions(path)
return accel_redirect(app.config["CACHE_ACCEL"], app.config["CACHE_PATH"], path)
def accel_redirect(internal, real, relative_name):
real_path = os.path.join(real, relative_name)
internal_path = os.path.join(internal, relative_name)
if not os.path.isfile(real_path):
abort(404)
mimetype = None
types = guess_type(real_path)
if len(types) != 0:
mimetype = types[0]
response = Response(mimetype=mimetype)
response.headers.add("X-Accel-Redirect", internal_path)
response.cache_control.public = True
if mimetype == "application/json":
response.cache_control.max_age = 3600
else:
response.cache_control.max_age = 29030400
return response
@app.route("/photos")
@jsonp
def photos():
f = open(os.path.join(app.config["CACHE_PATH"], "all_photos.json"), "r")
photos = json.load(f)
f.close()
if not is_authenticated():
def allowed(photo):
for auth_path in auth_list:
if photo.startswith(auth_path):
return False
return True
photos = [photo for photo in photos if allowed(photo)]
count = int(request.args.get("count", len(photos)))
random = request.args.get("random") == "true"
if random:
shuffle(photos)
else:
photos.reverse()
response = jsonify(photos=photos[0:count])
response.cache_control.no_cache = True
return response

View File

@ -5,14 +5,16 @@ import re
jsonp_validator = re.compile("^[a-zA-Z0-9_\-.]{1,128}$")
def jsonp(f):
"""Wraps JSONified output for JSONP"""
@wraps(f)
def decorated_function(*args, **kwargs):
callback = request.args.get('callback', False)
if callback and jsonp_validator.match(callback):
content = str(callback) + '(' + str(f(*args,**kwargs).data) + ')'
return current_app.response_class(content, mimetype='application/javascript')
else:
return f(*args, **kwargs)
return decorated_function
"""Wraps JSONified output for JSONP"""
@wraps(f)
def decorated_function(*args, **kwargs):
callback = request.args.get('callback', False)
if callback and jsonp_validator.match(callback):
content = str(callback) + '(' + str(f(*args, **kwargs).data) + ')'
return current_app.response_class(
content, mimetype='application/javascript')
else:
return f(*args, **kwargs)
return decorated_function

View File

@ -1,53 +1,35 @@
from floatapp import app, login_manager
from flask import request, abort
from flask_login import current_user, UserMixin
from functools import wraps
from flask import abort
from flask_login import UserMixin, LoginManager
login_manager = LoginManager()
class User(UserMixin):
def __init__(self, id, admin=False):
self.admin = admin
self.id = id
def __init__(self, id, admin=False):
self.admin = admin
self.id = id
def __unicode__(self):
return u"{}".format(self.id)
def __str__(self):
return str(self.id)
@property
def is_admin(self):
return self.admin
photo_user = User("user")
admin_user = User("admin", True)
@login_manager.user_loader
def load_user(id):
if id == "user":
return photo_user
elif id == "admin":
return admin_user
return None
if id == "admin":
return admin_user
return User(id)
@login_manager.unauthorized_handler
def unauthorized():
return abort(403)
def login_required(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if query_is_admin_user(request.args) or query_is_photo_user(request.args) or current_user.is_authenticated():
return fn(*args, **kwargs)
return app.login_manager.unauthorized()
return decorated_view
def admin_required(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if query_is_admin_user(request.args) or (current_user.is_authenticated() and current_user.admin):
return fn(*args, **kwargs)
return app.login_manager.unauthorized()
return decorated_view
def query_is_photo_user(query):
username = query.get("username", None)
password = query.get("password", None)
return username == app.config["PHOTO_USERNAME"] and password == app.config["PHOTO_PASSWORD"]
def query_is_admin_user(query):
username = query.get("username", None)
password = query.get("password", None)
return username == app.config["ADMIN_USERNAME"] and password == app.config["ADMIN_PASSWORD"]
def is_authenticated():
return query_is_admin_user(request.args) or query_is_photo_user(request.args) or current_user.is_authenticated()
return abort(403)

View File

@ -1,52 +1,63 @@
from flask import Response
import subprocess
import os
import sys
class ProcessWrapper(object):
def __init__(self, process, done):
self.process = process
self.done = done
def close(self):
self.done()
if self.process.returncode is not None:
return
self.process.stdout.close()
self.process.terminate()
self.process.wait()
def __iter__(self):
return self
def __del__(self):
self.close()
def next(self):
try:
data = self.process.stdout.readline()
except:
self.close()
raise StopIteration()
if data:
return data
self.close()
raise StopIteration()
def __init__(self, process, done):
self.process = process
self.done = done
def close(self):
self.done()
if self.process.returncode is not None:
return
self.process.stdout.close()
self.process.terminate()
self.process.wait()
def __iter__(self):
return self
def __del__(self):
self.close()
def next(self):
try:
data = self.process.stdout.readline()
except:
self.close()
raise StopIteration()
if data:
return data
self.close()
raise StopIteration()
__next__ = next
def send_process(args, pid_file):
def setup_proc():
f = open(pid_file, "w")
f.write(str(os.getpid()))
f.close()
os.close(0)
os.dup2(1, 2)
def tear_down_proc():
try:
os.unlink(pid_file)
except:
pass
if os.path.exists(pid_file):
f = open(pid_file, "r")
pid = f.read()
f.close()
if os.path.exists("/proc/%s/status" % pid):
return Response("Scanner is already running.\n", mimetype="text/plain")
process = subprocess.Popen(args, close_fds=True, stdout=subprocess.PIPE, preexec_fn=setup_proc)
response = ProcessWrapper(process, tear_down_proc)
return Response(response, direct_passthrough=True, mimetype="text/plain")
def setup_proc():
f = open(pid_file, "w")
f.write(str(os.getpid()))
f.close()
os.close(0)
os.dup2(1, 2)
def tear_down_proc():
try:
os.unlink(pid_file)
except:
pass
if os.path.exists(pid_file):
f = open(pid_file, "r")
pid = f.read()
f.close()
if os.path.exists("/proc/%s/status" % pid):
return Response(
"Scanner is already running.\n", mimetype="text/plain")
process = subprocess.Popen(
args, close_fds=True, stdout=subprocess.PIPE, preexec_fn=setup_proc)
response = ProcessWrapper(process, tear_down_proc)
return Response(response, direct_passthrough=True, mimetype="text/plain")

View File

@ -1,23 +1,25 @@
#!/usr/bin/env python2
#!./venv/bin/python
from TreeWalker import TreeWalker
from CachePath import message
import sys
import os
def main():
reload(sys)
sys.setdefaultencoding("UTF-8")
if len(sys.argv) != 3:
print "usage: %s ALBUM_PATH CACHE_PATH" % sys.argv[0]
return
try:
os.umask(022)
TreeWalker(sys.argv[1], sys.argv[2])
except KeyboardInterrupt:
message("keyboard", "CTRL+C pressed, quitting.")
sys.exit(-97)
def main():
reload(sys)
sys.setdefaultencoding("UTF-8")
if len(sys.argv) != 3:
print("usage: %s ALBUM_PATH CACHE_PATH" % sys.argv[0])
return
try:
os.umask(0o22)
TreeWalker(sys.argv[1], sys.argv[2])
except KeyboardInterrupt:
message("keyboard", "CTRL+C pressed, quitting.")
sys.exit(-97)
if __name__ == "__main__":
main()
main()

View File

@ -0,0 +1,3 @@
flask>=0.11
flask-login>=0.4.1
pillow>=5.3.0

1
web/css/.gitignore vendored
View File

@ -1 +0,0 @@
*.min.css

View File

@ -40,7 +40,7 @@ a:hover {
padding: 0;
}
.current-thumb {
border-top: 1px solid #FFAD27 !important;
border-top: 4px solid #FFAD27 !important;
}
#subalbums {
padding-top: 1.5em;
@ -63,7 +63,7 @@ a:hover {
#next, #back {
position: absolute;
width: auto;
font-size: 4.5em;
font-size: 12em;
line-height: 0;
top: 40%;
font-weight: bold;
@ -86,20 +86,24 @@ a:hover {
bottom: 150px;
top: 2.5em;
overflow: hidden;
margin-bottom: 0.5em;
left: 0;
right: 0;
text-align: center;
}
#photo-box {
#photo-box, #video-box {
display: inline;
}
#video-box-inner {
position: absolute;
top: 50%;
width: 100%;
}
#photo-links {
background-color: #000000;
font-weight: bold;
height: 10px;
font-size: 10px;
line-height: 7px;
height: 12px;
font-size: 12px;
line-height: 10px;
padding-top: 3px;
padding-bottom: 3px;
padding-right: 10px;
@ -162,6 +166,7 @@ a:hover {
white-space: nowrap;
padding: 0 !important;
text-align: center;
background-color: #999999;
}
#powered-by {
@ -194,6 +199,14 @@ a:hover {
font-weight: bold;
font-style: italic;
}
#video-unsupported {
background-image: url(../img/video-unsupported.png);
background-position: top center;
background-repeat: no-repeat;
padding-top: 96px;
}
#auth-text input {
color: rgb(0, 0, 0);
background-color: rgb(200, 200, 200);

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -1,41 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="fragment" content="!" />
<meta name="medium" content="image" />
<title>Photos</title>
<link href="css/styles.min.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="js/scripts.min.js"></script>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="medium" content="image" />
<title>Photos</title>
<link href="css/styles.min.css" rel="stylesheet" type="text/css" />
<script src="js/scripts.min.js"></script>
</head>
<body>
<div id="title">Photos</div>
<div id="photo-view">
<div id="photo-box">
<a id="next-photo"><img id="photo" /></a>
<div id="photo-bar">
<div id="photo-links">
<a id="metadata-link" href="javascript:void(0)">show metadata</a> | <a id="original-link" target="_blank">download original</a><span id="fullscreen-divider"> | </span><a id="fullscreen" href="javascript:void(0)">fullscreen</a>
</div>
<div id="metadata"></div>
</div>
</div>
<a id="back">&lsaquo;</a>
<a id="next">&rsaquo;</a>
</div>
<div id="album-view">
<div id="thumbs">
<div id="loading">Loading...</div>
</div>
<div id="subalbums"></div>
<div id="powered-by">Powered by <a href="http://www.zx2c4.com/projects/photofloat/" target="_blank">PhotoFloat</a></div>
</div>
<div id="title">Photos</div>
<div id="photo-view">
<div id="photo-box">
<a id="next-photo">
<img id="photo" alt="Dynamic image, no alt available" src="./img/image-placeholder.png" />
</a>
<div id="photo-bar">
<div id="photo-links">
<a id="metadata-link" href="javascript:void(0)">show metadata</a> | <a id="original-link">download original</a><span id="fullscreen-divider"> | </span><a id="fullscreen" href="javascript:void(0)">fullscreen</a>
</div>
<div id="metadata"></div>
</div>
</div>
<div id="video-box">
<div id="video-box-inner">
</div>
</div>
<a id="back">&lsaquo;</a>
<a id="next">&rsaquo;</a>
</div>
<div id="error-overlay"></div>
<div id="error-text">Forgot my camera.</div>
<div id="auth-text"><form id="auth-form"><input id="password" type="password" /><input type="submit" value="Login" /></form</div>
<div id="album-view">
<div id="thumbs">
<div id="loading">Loading...</div>
</div>
<div id="subalbums"></div>
<div id="powered-by">Powered by <a href="https://derdritte.net/gitea/markus/photofloat">subPhotoFloat</a></div>
</div>
<div id="error-overlay"></div>
<div id="error-text">Forgot my camera.</div>
<div id="auth-text">
<form id="auth-form">
<input id="password" type="password" />
<input type="submit" value="Login" />
</form>
</div>
</body>
</html>

1
web/js/.gitignore vendored
View File

@ -1 +0,0 @@
*.min.js

File diff suppressed because it is too large Load Diff

View File

@ -1,78 +1,221 @@
/*! Copyright (c) 2010 Brandon Aaron (http://brandonaaron.net)
* Licensed under the MIT License (LICENSE.txt).
/*!
* jQuery Mousewheel 3.1.12
*
* Thanks to: http://adomas.org/javascript-mouse-wheel/ for some pointers.
* Thanks to: Mathias Bank(http://www.mathias-bank.de) for a scope bug fix.
* Thanks to: Seamus Leahy for adding deltaX and deltaY
*
* Version: 3.0.4
*
* Requires: 1.2.2+
* Copyright 2014 jQuery Foundation and other contributors
* Released under the MIT license.
* http://jquery.org/license
*/
(function($) {
(function (factory) {
if ( typeof define === 'function' && define.amd ) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node/CommonJS style for Browserify
module.exports = factory;
} else {
// Browser globals
factory(jQuery);
}
}(function ($) {
var types = ['DOMMouseScroll', 'mousewheel'];
var toFix = ['wheel', 'mousewheel', 'DOMMouseScroll', 'MozMousePixelScroll'],
toBind = ( 'onwheel' in document || document.documentMode >= 9 ) ?
['wheel'] : ['mousewheel', 'DomMouseScroll', 'MozMousePixelScroll'],
slice = Array.prototype.slice,
nullLowestDeltaTimeout, lowestDelta;
$.event.special.mousewheel = {
setup: function() {
if ( this.addEventListener ) {
for ( var i=types.length; i; ) {
this.addEventListener( types[--i], handler, false );
}
} else {
this.onmousewheel = handler;
}
},
teardown: function() {
if ( this.removeEventListener ) {
for ( var i=types.length; i; ) {
this.removeEventListener( types[--i], handler, false );
}
} else {
this.onmousewheel = null;
if ( $.event.fixHooks ) {
for ( var i = toFix.length; i; ) {
$.event.fixHooks[ toFix[--i] ] = $.event.mouseHooks;
}
}
};
$.fn.extend({
mousewheel: function(fn) {
return fn ? this.bind("mousewheel", fn) : this.trigger("mousewheel");
},
unmousewheel: function(fn) {
return this.unbind("mousewheel", fn);
var special = $.event.special.mousewheel = {
version: '3.1.12',
setup: function() {
if ( this.addEventListener ) {
for ( var i = toBind.length; i; ) {
this.addEventListener( toBind[--i], handler, false );
}
} else {
this.onmousewheel = handler;
}
// Store the line height and page height for this particular element
$.data(this, 'mousewheel-line-height', special.getLineHeight(this));
$.data(this, 'mousewheel-page-height', special.getPageHeight(this));
},
teardown: function() {
if ( this.removeEventListener ) {
for ( var i = toBind.length; i; ) {
this.removeEventListener( toBind[--i], handler, false );
}
} else {
this.onmousewheel = null;
}
// Clean up the data we added to the element
$.removeData(this, 'mousewheel-line-height');
$.removeData(this, 'mousewheel-page-height');
},
getLineHeight: function(elem) {
var $elem = $(elem),
$parent = $elem['offsetParent' in $.fn ? 'offsetParent' : 'parent']();
if (!$parent.length) {
$parent = $('body');
}
return parseInt($parent.css('fontSize'), 10) || parseInt($elem.css('fontSize'), 10) || 16;
},
getPageHeight: function(elem) {
return $(elem).height();
},
settings: {
adjustOldDeltas: true, // see shouldAdjustOldDeltas() below
normalizeOffset: true // calls getBoundingClientRect for each event
}
};
$.fn.extend({
mousewheel: function(fn) {
return fn ? this.bind('mousewheel', fn) : this.trigger('mousewheel');
},
unmousewheel: function(fn) {
return this.unbind('mousewheel', fn);
}
});
function handler(event) {
var orgEvent = event || window.event,
args = slice.call(arguments, 1),
delta = 0,
deltaX = 0,
deltaY = 0,
absDelta = 0,
offsetX = 0,
offsetY = 0;
event = $.event.fix(orgEvent);
event.type = 'mousewheel';
// Old school scrollwheel delta
if ( 'detail' in orgEvent ) { deltaY = orgEvent.detail * -1; }
if ( 'wheelDelta' in orgEvent ) { deltaY = orgEvent.wheelDelta; }
if ( 'wheelDeltaY' in orgEvent ) { deltaY = orgEvent.wheelDeltaY; }
if ( 'wheelDeltaX' in orgEvent ) { deltaX = orgEvent.wheelDeltaX * -1; }
// Firefox < 17 horizontal scrolling related to DOMMouseScroll event
if ( 'axis' in orgEvent && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
deltaX = deltaY * -1;
deltaY = 0;
}
// Set delta to be deltaY or deltaX if deltaY is 0 for backwards compatabilitiy
delta = deltaY === 0 ? deltaX : deltaY;
// New school wheel delta (wheel event)
if ( 'deltaY' in orgEvent ) {
deltaY = orgEvent.deltaY * -1;
delta = deltaY;
}
if ( 'deltaX' in orgEvent ) {
deltaX = orgEvent.deltaX;
if ( deltaY === 0 ) { delta = deltaX * -1; }
}
// No change actually happened, no reason to go any further
if ( deltaY === 0 && deltaX === 0 ) { return; }
// Need to convert lines and pages to pixels if we aren't already in pixels
// There are three delta modes:
// * deltaMode 0 is by pixels, nothing to do
// * deltaMode 1 is by lines
// * deltaMode 2 is by pages
if ( orgEvent.deltaMode === 1 ) {
var lineHeight = $.data(this, 'mousewheel-line-height');
delta *= lineHeight;
deltaY *= lineHeight;
deltaX *= lineHeight;
} else if ( orgEvent.deltaMode === 2 ) {
var pageHeight = $.data(this, 'mousewheel-page-height');
delta *= pageHeight;
deltaY *= pageHeight;
deltaX *= pageHeight;
}
// Store lowest absolute delta to normalize the delta values
absDelta = Math.max( Math.abs(deltaY), Math.abs(deltaX) );
if ( !lowestDelta || absDelta < lowestDelta ) {
lowestDelta = absDelta;
// Adjust older deltas if necessary
if ( shouldAdjustOldDeltas(orgEvent, absDelta) ) {
lowestDelta /= 40;
}
}
// Adjust older deltas if necessary
if ( shouldAdjustOldDeltas(orgEvent, absDelta) ) {
// Divide all the things by 40!
delta /= 40;
deltaX /= 40;
deltaY /= 40;
}
// Get a whole, normalized value for the deltas
delta = Math[ delta >= 1 ? 'floor' : 'ceil' ](delta / lowestDelta);
deltaX = Math[ deltaX >= 1 ? 'floor' : 'ceil' ](deltaX / lowestDelta);
deltaY = Math[ deltaY >= 1 ? 'floor' : 'ceil' ](deltaY / lowestDelta);
// Normalise offsetX and offsetY properties
if ( special.settings.normalizeOffset && this.getBoundingClientRect ) {
var boundingRect = this.getBoundingClientRect();
offsetX = event.clientX - boundingRect.left;
offsetY = event.clientY - boundingRect.top;
}
// Add information to the event object
event.deltaX = deltaX;
event.deltaY = deltaY;
event.deltaFactor = lowestDelta;
event.offsetX = offsetX;
event.offsetY = offsetY;
// Go ahead and set deltaMode to 0 since we converted to pixels
// Although this is a little odd since we overwrite the deltaX/Y
// properties with normalized deltas.
event.deltaMode = 0;
// Add event and delta to the front of the arguments
args.unshift(event, delta, deltaX, deltaY);
// Clearout lowestDelta after sometime to better
// handle multiple device types that give different
// a different lowestDelta
// Ex: trackpad = 3 and mouse wheel = 120
if (nullLowestDeltaTimeout) { clearTimeout(nullLowestDeltaTimeout); }
nullLowestDeltaTimeout = setTimeout(nullLowestDelta, 200);
return ($.event.dispatch || $.event.handle).apply(this, args);
}
});
function handler(event) {
var orgEvent = event || window.event, args = [].slice.call( arguments, 1 ), delta = 0, returnValue = true, deltaX = 0, deltaY = 0;
event = $.event.fix(orgEvent);
event.type = "mousewheel";
// Old school scrollwheel delta
if ( event.wheelDelta ) { delta = event.wheelDelta/120; }
if ( event.detail ) { delta = -event.detail/3; }
// New school multidimensional scroll (touchpads) deltas
deltaY = delta;
// Gecko
if ( orgEvent.axis !== undefined && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
deltaY = 0;
deltaX = -1*delta;
function nullLowestDelta() {
lowestDelta = null;
}
// Webkit
if ( orgEvent.wheelDeltaY !== undefined ) { deltaY = orgEvent.wheelDeltaY/120; }
if ( orgEvent.wheelDeltaX !== undefined ) { deltaX = -1*orgEvent.wheelDeltaX/120; }
// Add event and delta to the front of the arguments
args.unshift(event, delta, deltaX, deltaY);
return $.event.handle.apply(this, args);
}
})(jQuery);
function shouldAdjustOldDeltas(orgEvent, absDelta) {
// If this is an older event and the delta is divisable by 120,
// then we are assuming that the browser is treating this as an
// older mouse wheel event and that we should divide the deltas
// by 40 to try and get a more usable deltaFactor.
// Side note, this actually impacts the reported scroll distance
// in older browsers and can cause scrolling to be slower than native.
// Turn this off by setting $.event.special.mousewheel.settings.adjustOldDeltas to false.
return special.settings.adjustOldDeltas && orgEvent.type === 'mousewheel' && absDelta % 120 === 0;
}
}));

File diff suppressed because it is too large Load Diff

View File

@ -92,7 +92,7 @@
$.ajax({
type: "GET",
dataType: "text",
url: "auth?username=photos&password=" + password,
url: "auth?username=" + password,
success: function() {
result(true);
},
@ -147,6 +147,9 @@
hash = hash.substring(5);
return "cache/" + hash;
};
PhotoFloat.videoPath = function(album, video) {
return "cache/" + PhotoFloat.cachePath(PhotoFloat.photoHash(album, video) + ".mp4");
};
PhotoFloat.originalPhotoPath = function(album, photo) {
return "albums/" + album.path + "/" + photo.name;
};
@ -179,6 +182,7 @@
PhotoFloat.prototype.photoHash = PhotoFloat.photoHash;
PhotoFloat.prototype.albumHash = PhotoFloat.albumHash;
PhotoFloat.prototype.photoPath = PhotoFloat.photoPath;
PhotoFloat.prototype.videoPath = PhotoFloat.videoPath;
PhotoFloat.prototype.originalPhotoPath = PhotoFloat.originalPhotoPath;
PhotoFloat.prototype.trimExtension = PhotoFloat.trimExtension;
PhotoFloat.prototype.cleanHash = PhotoFloat.cleanHash;

View File

@ -0,0 +1,135 @@
/*
Xwiper
Provided by https://github.com/uxitten/xwiper/
*/
'use strict';
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
var Xwiper = function () {
function Xwiper(element) {
_classCallCheck(this, Xwiper);
this.element = null;
this.touchStartX = 0;
this.touchStartY = 0;
this.touchEndX = 0;
this.touchEndY = 0;
this.sensitive = 50;
this.onSwipeLeftAgent = null;
this.onSwipeRightAgent = null;
this.onSwipeUpAgent = null;
this.onSwipeDownAgent = null;
this.onTapAgent = null;
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
this.onSwipeLeft = this.onSwipeLeft.bind(this);
this.onSwipeRight = this.onSwipeRight.bind(this);
this.onSwipeUp = this.onSwipeUp.bind(this);
this.onSwipeDown = this.onSwipeDown.bind(this);
this.onTap = this.onTap.bind(this);
this.destroy = this.destroy.bind(this);
this.handleGesture = this.handleGesture.bind(this);
this.element = document.querySelector(element);
this.element.addEventListener('touchstart', this.onTouchStart, false);
this.element.addEventListener('touchend', this.onTouchEnd, false);
}
_createClass(Xwiper, [{
key: 'onTouchStart',
value: function onTouchStart(event) {
this.touchStartX = event.changedTouches[0].screenX;
this.touchStartY = event.changedTouches[0].screenY;
}
}, {
key: 'onTouchEnd',
value: function onTouchEnd(event) {
this.touchEndX = event.changedTouches[0].screenX;
this.touchEndY = event.changedTouches[0].screenY;
this.handleGesture();
}
}, {
key: 'onSwipeLeft',
value: function onSwipeLeft(func) {
this.onSwipeLeftAgent = func;
}
}, {
key: 'onSwipeRight',
value: function onSwipeRight(func) {
this.onSwipeRightAgent = func;
}
}, {
key: 'onSwipeUp',
value: function onSwipeUp(func) {
this.onSwipeUpAgent = func;
}
}, {
key: 'onSwipeDown',
value: function onSwipeDown(func) {
this.onSwipeDownAgent = func;
}
}, {
key: 'onTap',
value: function onTap(func) {
this.onTapAgent = func;
}
}, {
key: 'destroy',
value: function destroy() {
this.element.removeEventListener('touchstart', this.onTouchStart);
this.element.removeEventListener('touchend', this.onTouchEnd);
}
}, {
key: 'handleGesture',
value: function handleGesture() {
/**
* swiped left
*/
if (this.touchEndX + this.sensitive <= this.touchStartX) {
this.onSwipeLeftAgent && this.onSwipeLeftAgent();
return 'swiped left';
}
/**
* swiped right
*/
if (this.touchEndX - this.sensitive >= this.touchStartX) {
this.onSwipeRightAgent && this.onSwipeRightAgent();
return 'swiped right';
}
/**
* swiped up
*/
if (this.touchEndY + this.sensitive <= this.touchStartY) {
this.onSwipeUpAgent && this.onSwipeUpAgent();
return 'swiped up';
}
/**
* swiped down
*/
if (this.touchEndY - this.sensitive >= this.touchStartY) {
this.onSwipeDownAgent && this.onSwipeDownAgent();
return 'swiped down';
}
/**
* tap
*/
if (this.touchEndY === this.touchStartY) {
this.onTapAgent && this.onTapAgent();
return 'tap';
}
}
}]);
return Xwiper;
}();

View File

@ -27,7 +27,7 @@ $(document).ready(function() {
var previousPhoto = null;
var originalTitle = document.title;
var photoFloat = new PhotoFloat();
var maxSize = 800;
var maxSize = 1024;
/* Displays */
@ -98,6 +98,8 @@ $(document).ready(function() {
for (i = 0; i < currentAlbum.photos.length; ++i) {
link = $("<a href=\"#!/" + photoFloat.photoHash(currentAlbum, currentAlbum.photos[i]) + "\"></a>");
image = $("<img title=\"" + photoFloat.trimExtension(currentAlbum.photos[i].name) + "\" alt=\"" + photoFloat.trimExtension(currentAlbum.photos[i].name) + "\" src=\"" + photoFloat.photoPath(currentAlbum, currentAlbum.photos[i], 150, true) + "\" height=\"150\" width=\"150\" />");
if (currentAlbum.photos[i].mediaType == "video")
image.css("background-image", "url(" + image.attr("src") + ")").attr("src", "img/video-icon.png");
image.get(0).photo = currentAlbum.photos[i];
link.append(image);
photos.push(link);
@ -145,6 +147,8 @@ $(document).ready(function() {
$("#album-view").removeClass("photo-view-container");
$("#subalbums").show();
$("#photo-view").hide();
$("#video-box-inner").empty();
$("#video-box").hide();
}
setTimeout(scrollToThumb, 1);
}
@ -160,30 +164,73 @@ $(document).ready(function() {
$(window).bind("resize", scaleImage);
container = $("#photo-view");
if (image.css("width") !== "100%" && container.height() * image.attr("ratio") > container.width())
image.css("width", "100%").css("height", "auto").css("position", "absolute").css("bottom", 0);
image.css("width", "100%").css("height", "auto");
else if (image.css("height") !== "100%")
image.css("height", "100%").css("width", "auto").css("position", "").css("bottom", "");
image.css("height", "100%").css("width", "auto");
}
function scaleVideo() {
var video, container;
video = $("#video");
if (video.get(0) === this)
$(window).bind("resize", scaleVideo);
container = $("#photo-view");
if (video.attr("width") > container.width() && container.height() * video.attr("ratio") > container.width())
video.css("width", container.width()).css("height", container.width() / video.attr("ratio")).parent().css("height", container.width() / video.attr("ratio")).css("margin-top", - container.width() / video.attr("ratio") / 2).css("top", "50%");
else if (video.attr("height") > container.height() && container.height() * video.attr("ratio") < container.width())
video.css("height", container.height()).css("width", container.height() * video.attr("ratio")).parent().css("height", "100%").css("margin-top", "0").css("top", "0");
else
video.css("height", "").css("width", "").parent().css("height", video.attr("height")).css("margin-top", - video.attr("height") / 2).css("top", "50%");
}
function showPhoto() {
var width, height, photoSrc, previousPhoto, nextPhoto, nextLink, text;
var width, height, photoSrc, videoSrc, previousPhoto, nextPhoto, nextLink, text;
width = currentPhoto.size[0];
height = currentPhoto.size[1];
if (width > height) {
height = height / width * maxSize;
width = maxSize;
if (currentPhoto.mediaType == "video") {
$("#video-box-inner").empty();
if (!Modernizr.video) {
$('<div id="video-unsupported"><p>Sorry, your browser doesn\'t support the HTML5 &lt;video&gt; element!</p><p>Here\'s a <a href="http://caniuse.com/video">list of which browsers do</a>.</p></div>').appendTo('#video-box-inner');
}
else if (!Modernizr.video.h264) {
$('<div id="video-unsupported"><p>Sorry, your browser doesn\'t support the H.264 video format!</p></div>').appendTo('#video-box-inner');
} else {
$(window).unbind("resize", scaleVideo);
$(window).unbind("resize", scaleImage);
videoSrc = photoFloat.videoPath(currentAlbum, currentPhoto);
$('<video/>', { id: 'video', controls: true }).appendTo('#video-box-inner')
.attr("width", width).attr("height", height).attr("ratio", currentPhoto.size[0] / currentPhoto.size[1])
.attr("src", videoSrc)
.attr("alt", currentPhoto.name)
.on('loadstart', scaleVideo);
}
$("head").append("<link rel=\"video_src\" href=\"" + videoSrc + "\" />");
$("#video-box-inner").css('height', height + 'px').css('margin-top', - height / 2);
$("#photo-box").hide();
$("#video-box").show();
} else {
width = width / height * maxSize;
height = maxSize;
width = currentPhoto.size[0];
height = currentPhoto.size[1];
if (width > height) {
height = height / width * maxSize;
width = maxSize;
} else {
width = width / height * maxSize;
height = maxSize;
}
$(window).unbind("resize", scaleVideo);
$(window).unbind("resize", scaleImage);
photoSrc = photoFloat.photoPath(currentAlbum, currentPhoto, maxSize, false);
$("#photo")
.attr("width", width).attr("height", height).attr("ratio", currentPhoto.size[0] / currentPhoto.size[1])
.attr("src", photoSrc)
.attr("alt", currentPhoto.name)
.attr("title", currentPhoto.date)
.load(scaleImage);
$("head").append("<link rel=\"image_src\" href=\"" + photoSrc + "\" />");
$("#video-box-inner").empty();
$("#video-box").hide();
$("#photo-box").show();
}
$(window).unbind("resize", scaleImage);
photoSrc = photoFloat.photoPath(currentAlbum, currentPhoto, maxSize, false);
$("#photo")
.attr("width", width).attr("height", height).attr("ratio", currentPhoto.size[0] / currentPhoto.size[1])
.attr("src", photoSrc)
.attr("alt", currentPhoto.name)
.attr("title", currentPhoto.date)
.load(scaleImage);
$("head").append("<link rel=\"image_src\" href=\"" + photoSrc + "\" />");
previousPhoto = currentAlbum.photos[
(currentPhotoIndex - 1 < 0) ? (currentAlbum.photos.length - 1) : (currentPhotoIndex - 1)
@ -197,7 +244,7 @@ $(document).ready(function() {
$("#next-photo").attr("href", nextLink);
$("#next").attr("href", nextLink);
$("#back").attr("href", "#!/" + photoFloat.photoHash(currentAlbum, previousPhoto));
$("#original-link").attr("target", "_blank").attr("href", photoFloat.originalPhotoPath(currentAlbum, currentPhoto));
$("#original-link").attr("href", photoFloat.originalPhotoPath(currentAlbum, currentPhoto));
text = "<table>";
if (typeof currentPhoto.make !== "undefined") text += "<tr><td>Camera Maker</td><td>" + currentPhoto.make + "</td></tr>";
@ -257,9 +304,9 @@ $(document).ready(function() {
currentPhoto = photo;
currentPhotoIndex = photoIndex;
setTitle();
showAlbum(previousAlbum !== currentAlbum);
if (photo !== null)
showPhoto();
showAlbum(previousAlbum !== currentAlbum);
}
/* Event listeners */
@ -267,13 +314,11 @@ $(document).ready(function() {
$(window).hashchange(function() {
$("#loading").show();
$("link[rel=image_src]").remove();
if (location.search.indexOf("?_escaped_fragment_=") === 0) {
location.hash = location.search.substring(20);
location.search = "";
}
$("link[rel=video_src]").remove();
photoFloat.parseHash(location.hash, hashParsed, die);
});
$(window).hashchange();
/* Keyboard: Left / Right */
$(document).keydown(function(e){
if (currentPhoto === null)
return true;
@ -286,6 +331,7 @@ $(document).ready(function() {
}
return true;
});
/* Mousewheel */
$(document).mousewheel(function(event, delta) {
if (currentPhoto === null)
return true;
@ -298,6 +344,16 @@ $(document).ready(function() {
}
return true;
});
/* Swipe */
xwiper = new Xwiper('#photo-view');
xwiper.onSwipeLeft(function(event){
window.location.href = $("#next").attr("href");
});
xwiper.onSwipeRight(function(event){
window.location.href = $("#back").attr("href");
});
$("#photo-box").mouseenter(function() {
$("#photo-links").stop().fadeTo("slow", 0.50).css("display", "inline");
});
@ -314,7 +370,7 @@ $(document).ready(function() {
$("#fullscreen-divider").show();
$("#fullscreen").show().click(function() {
$("#photo").fullScreen({callback: function(isFullscreen) {
maxSize = isFullscreen ? 1024 : 800;
maxSize = isFullscreen ? 1600 : 1024;
showPhoto();
}});
});