piccle 0.1.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +4 -0
  6. data/NOTES.md +69 -0
  7. data/README.md +175 -0
  8. data/Rakefile +8 -0
  9. data/agpl-3.0.md +660 -0
  10. data/assets/css/default.css +397 -0
  11. data/assets/css/normalize.css +427 -0
  12. data/assets/icons/android-chrome-192x192.png +0 -0
  13. data/assets/icons/android-chrome-512x512.png +0 -0
  14. data/assets/icons/apple-touch-icon.png +0 -0
  15. data/assets/icons/favicon-16x16.png +0 -0
  16. data/assets/icons/favicon-32x32.png +0 -0
  17. data/assets/icons/favicon.ico +0 -0
  18. data/bin/console +14 -0
  19. data/bin/piccle +355 -0
  20. data/bin/setup +8 -0
  21. data/db/migrations/001_create_photos.rb +15 -0
  22. data/db/migrations/002_update_photos.rb +14 -0
  23. data/db/migrations/003_create_keywords_and_join_table.rb +14 -0
  24. data/db/migrations/004_add_focal_length.rb +7 -0
  25. data/db/migrations/005_create_locations.rb +20 -0
  26. data/js-renderer/handlebars.min-v4.7.6.js +29 -0
  27. data/js-renderer/renderer.js +93 -0
  28. data/lib/piccle.rb +52 -0
  29. data/lib/piccle/config.rb +136 -0
  30. data/lib/piccle/database.rb +33 -0
  31. data/lib/piccle/dstk_service.rb +64 -0
  32. data/lib/piccle/extractor.rb +128 -0
  33. data/lib/piccle/js_renderer.rb +37 -0
  34. data/lib/piccle/models/keyword.rb +6 -0
  35. data/lib/piccle/models/location.rb +11 -0
  36. data/lib/piccle/models/photo.rb +211 -0
  37. data/lib/piccle/parser.rb +230 -0
  38. data/lib/piccle/quilt_generator.rb +30 -0
  39. data/lib/piccle/renderer.rb +175 -0
  40. data/lib/piccle/streams.rb +2 -0
  41. data/lib/piccle/streams/base_stream.rb +56 -0
  42. data/lib/piccle/streams/camera_stream.rb +35 -0
  43. data/lib/piccle/streams/date_stream.rb +95 -0
  44. data/lib/piccle/streams/event_stream.rb +73 -0
  45. data/lib/piccle/streams/keyword_stream.rb +24 -0
  46. data/lib/piccle/streams/location_stream.rb +57 -0
  47. data/lib/piccle/template_helpers.rb +79 -0
  48. data/lib/piccle/version.rb +3 -0
  49. data/lib/tasks/development.rake +38 -0
  50. data/piccle.gemspec +43 -0
  51. data/templates/_breadcrumbs.handlebars.slim +16 -0
  52. data/templates/_footer.handlebars.slim +2 -0
  53. data/templates/_header.handlebars.slim +36 -0
  54. data/templates/_navigation.handlebars.slim +16 -0
  55. data/templates/_substream.handlebars.slim +17 -0
  56. data/templates/feed.atom.slim +29 -0
  57. data/templates/index.html.handlebars.slim +36 -0
  58. data/templates/show.html.handlebars.slim +64 -0
  59. metadata +340 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Browse photos by keyword.
4
+ class Piccle::Streams::KeywordStream < Piccle::Streams::BaseStream
5
+ def namespace
6
+ "by-topic"
7
+ end
8
+
9
+ # Standard method called by the parser object. Returns a hash that contains the data to merge for the given photo.
10
+ def data_for(photo)
11
+ result = { namespace => {
12
+ :friendly_name => "By Topic",
13
+ :interesting => true
14
+ }}
15
+ photo.keywords.each do |kw|
16
+ result[namespace][slugify(kw.name)] = { friendly_name: kw.name, interesting: true, photos: [photo.md5] }
17
+ end
18
+ result
19
+ end
20
+
21
+ def metadata_for(photo)
22
+ photo.keywords.map { |kw| { friendly_name: kw.name, type: :keyword, selector: [namespace, slugify(kw.name)] } }
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enables browsing photos by location.
4
+ class Piccle::Streams::LocationStream < Piccle::Streams::BaseStream
5
+ def namespace
6
+ "by-location"
7
+ end
8
+
9
+ def data_for(photo)
10
+ data = {}
11
+ if photo.country
12
+ data = { namespace => {
13
+ :friendly_name => "By Location",
14
+ :interesting => false,
15
+ photo.country.downcase => {
16
+ :friendly_name => photo.country,
17
+ :interesting => true,
18
+ :photos => [photo.md5]
19
+ },
20
+ }}
21
+ if photo.state
22
+ data[namespace][photo.country.downcase][photo.state.downcase] = {
23
+ :friendly_name => photo.state,
24
+ :interesting => false,
25
+ :photos => [photo.md5]
26
+ }
27
+
28
+ if photo.city
29
+ data[namespace][photo.country.downcase][photo.state.downcase][photo.city.downcase] = {
30
+ :friendly_name => photo.city,
31
+ :interesting => false,
32
+ :photos => [photo.md5]
33
+ }
34
+ end
35
+ end
36
+ end
37
+
38
+ data
39
+ end
40
+
41
+ def metadata_for(photo)
42
+ metadata = []
43
+
44
+ if photo.country
45
+ metadata << { friendly_name: photo.country, type: :location_country, selector: [namespace, photo.country.downcase] }
46
+
47
+ if photo.state
48
+ metadata << { friendly_name: photo.state, type: :location_state, selector: [namespace, photo.country.downcase, photo.state.downcase] }
49
+
50
+ if photo.city
51
+ metadata << { friendly_name: photo.city, type: :location_city, selector: [namespace, photo.country.downcase, photo.state.downcase, photo.city.downcase] }
52
+ end
53
+ end
54
+ end
55
+ metadata
56
+ end
57
+ end
@@ -0,0 +1,79 @@
1
+ require 'cgi'
2
+ require 'handlebars'
3
+
4
+ # Rendering functions for templates.
5
+ # * For most files, we use Slim to render a Handlebars template (ie. HTML with embedded Handlebars). Handlebars then
6
+ # generates the final output. This lets us use the same templates for backend and frontend rendering.
7
+ # * Partials don't have variable interpolation, because they're rendered inline into the main template. Their variables
8
+ # will be interpolated when the overall template is rendered.
9
+ # * RSS feeds are generated via Slim only, as we don't need to generate those server-side.
10
+ #
11
+ # We don't cache Handlebars templates, because it actually results in WORSE performance than compiling a fresh instance
12
+ # each time.
13
+
14
+ class Piccle::TemplateHelpers
15
+ @@handlebars = nil
16
+ @@slim_pages = {}
17
+ @@slim_partials = {}
18
+
19
+ # Renders a partial template. Partial templates do NOT have their variables interpolated via Handlebars.
20
+ def self.render_partial(template_name, args = {})
21
+ @@slim_partials[template_name] ||= Tilt['slim'].new { File.read(Piccle.config.gem_root_join("templates/_#{template_name}.handlebars.slim")) }
22
+ @@slim_partials[template_name].render(Object.new, args)
23
+ end
24
+
25
+ # Renders an entire template out
26
+ def self.render(template_name, data = {})
27
+ options = { code_attr_delims: { '(' => ')', '[' => ']'}, attr_list_delims: { '(' => ')', '[' => ']' } }
28
+ @@slim_pages[template_name] ||= Tilt['slim'].new(options) { File.read(Piccle.config.gem_root_join("templates/#{template_name}.html.handlebars.slim")) }.render
29
+ template = handlebars.compile(@@slim_pages[template_name])
30
+ template.call(data)
31
+ end
32
+
33
+ def self.render_rss(template_name, data = {})
34
+ @@slim_pages["rss_#{template_name}"] ||= Tilt['slim'].new { File.read(Piccle.config.gem_root_join("templates/#{template_name}.atom.slim")) }
35
+ @@slim_pages["rss_#{template_name}"].render(Object.new, data)
36
+ end
37
+
38
+ # Gets a Handlebars version of the template. No variable replacement!
39
+ def self.compile_template(name)
40
+ slim_template = Tilt['slim'].new { File.read(Piccle.config.gem_root_join("templates/#{name}.html.handlebars.slim")) }
41
+ slim_template.render(Object.new, {})
42
+ end
43
+
44
+ # Given a "selector" (an array of string path components), returns an "include prefix" (a relative path that
45
+ # gets us back to the top level).
46
+ # eg. ["by-date", "2017", "03"] → "../../../"
47
+ def self.include_prefix(selector)
48
+ if selector.any?
49
+ "#{(['..'] * selector.length).join('/')}/"
50
+ else
51
+ ""
52
+ end
53
+ end
54
+
55
+ # Given a block of content, escape its HTML.
56
+ def self.escape_html(&block)
57
+ CGI::escape_html(yield)
58
+ end
59
+
60
+ protected
61
+
62
+ def self.handlebars
63
+ unless @@handlebars
64
+ @@handlebars = Handlebars::Context.new
65
+ @@handlebars.register_helper(:ifEqual) do |context, arg1, arg2, block|
66
+ if arg1 == arg2
67
+ block.fn(context)
68
+ else
69
+ block.inverse(context)
70
+ end
71
+ end
72
+
73
+ @@handlebars.register_helper(:join) do |context, arg1, arg2, block|
74
+ arg1.join(arg2)
75
+ end
76
+ end
77
+ @@handlebars
78
+ end
79
+ end
@@ -0,0 +1,3 @@
1
+ module Piccle
2
+ VERSION = "0.1.0.rc1"
3
+ end
@@ -0,0 +1,38 @@
1
+ require "sqlite3"
2
+
3
+ namespace :db do
4
+ desc "Create a database for photo data"
5
+ task :initialise do
6
+ puts "This task is deprecated; run sequel -m db/migrations sqlite://photo_data.db instead."
7
+ # # If a DB already exists, do nothing.
8
+ # if File.exist?(Piccle::Database::PHOTO_DATABASE_FILENAME)
9
+ # puts "Database #{Piccle::Database::PHOTO_DATABASE_FILENAME} already exists; exiting."
10
+ # else
11
+ # puts "Creating an empty DB..."
12
+ # db = SQLite3::Database.new(Piccle::Database::PHOTO_DATABASE_FILENAME)
13
+ # puts " ... and writing a schema to it..."
14
+ # db.execute <<-SQL
15
+ # CREATE TABLE photos(
16
+ # id INTEGER PRIMARY KEY,
17
+ # file_name text NOT NULL,
18
+ # path text NOT NULL,
19
+ # md5 varchar(128) NOT NULL,
20
+ # width integer NOT NULL,
21
+ # height integer NOT NULL,
22
+ # camera_name text,
23
+ # taken_at datetime,
24
+ # created_at datetime
25
+ # );
26
+ # SQL
27
+ # puts " ... Done."
28
+ # end
29
+ end
30
+
31
+ desc "Drop the database"
32
+ task :drop do
33
+ File.delete("photo_data.db")
34
+ end
35
+
36
+ desc "Recreate the database"
37
+ task recreate: [:drop, :initialise]
38
+ end
@@ -0,0 +1,43 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'piccle/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "piccle"
8
+ spec.version = Piccle::VERSION
9
+ spec.authors = ["Alex Pounds"]
10
+ spec.email = ["piccle@alexpounds.com"]
11
+ spec.executables = ["piccle"]
12
+
13
+ spec.summary = "A static site generator for photographers"
14
+ spec.description = "Piccle uses the EXIF data present in your photographs and uses it to build a website that lets
15
+ visitors browse by camera, tag, location, and more."
16
+ spec.homepage = "https://piccle.alexpounds.com/"
17
+
18
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
19
+ f.match(%r{^(test|spec|features)/})
20
+ end
21
+ spec.bindir = "bin"
22
+ spec.executables = ["piccle"]
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.13"
26
+ spec.add_development_dependency "rspec", "~> 3.0" # Testing library
27
+ spec.add_development_dependency "simplecov" # Code coverage calculator
28
+ spec.add_development_dependency "simplecov-console" # Output stats on the console
29
+ spec.add_development_dependency "pry-byebug", "~> 3.5" # Debugging aid; only needed by developers.
30
+ spec.add_development_dependency "ruby-prof" # Performance profiling.
31
+
32
+ spec.add_dependency "exifr", "~> 1.3" # EXIF reading library
33
+ spec.add_dependency "handlebars" # Templating engine, usable both backend and frontend.
34
+ spec.add_dependency "httparty" # Simple HTTP library
35
+ spec.add_dependency "rake" # Ruby task runner
36
+ spec.add_dependency "recursive-open-struct", "~> 1.0" # Blesses database results into objects compatible with flavour-saver
37
+ spec.add_dependency "rmagick", "~> 2.0" # Image processing library
38
+ spec.add_dependency "sequel", "~> 5" # DB access in a structured way
39
+ spec.add_dependency "slim", "~> 3.0" # Templating language, so we don't have to write longhand HTML
40
+ spec.add_dependency "sqlite3", "~> 1.3" # Simple file-based database
41
+ spec.add_dependency "thor" # Nice Ruby CLI builder
42
+ spec.add_dependency "xmp", "~> 0.2" # Read XMP info from files
43
+ end
@@ -0,0 +1,16 @@
1
+ |
2
+ {{#if breadcrumbs}}
3
+ <ol>
4
+ {{#each breadcrumbs as |crumb|}}
5
+ {{#if @last}}
6
+ <li>{{crumb.friendly_name}}</li>
7
+ {{else}}
8
+ {{#if crumb.link}}
9
+ <li><a href="{{../include_prefix}}{{crumb.link}}">{{crumb.friendly_name}}</a></li>
10
+ {{else}}
11
+ <li>{{crumb.friendly_name}}</li>
12
+ {{/if}}
13
+ {{/if}}
14
+ {{/each}}
15
+ </ol>
16
+ {{/if}}
@@ -0,0 +1,2 @@
1
+ footer
2
+ | Generated by <a href="https://piccle.alexpounds.com/">Piccle</a>. Photography &copy; {{site_metadata.copyright_year}} {{site_metadata.author_name}}.
@@ -0,0 +1,36 @@
1
+ head
2
+ meta charset="utf-8"
3
+ meta name="viewport" content="width=device-width, initial-scale=1"
4
+ title Photography by {{site_metadata.author_name}}
5
+ link rel="stylesheet" href="{{include_prefix}}css/normalize.css"
6
+ link rel="stylesheet" href="{{include_prefix}}css/default.css"
7
+ link rel="icon" href="{{include_prefix}}icons/favicon.ico"
8
+ link rel="icon" sizes="32x32" type="image/png" href="{{include_prefix}}icons/favicon-32x32.png"
9
+ link rel="icon" sizes="16x16" type="image/png" href="{{include_prefix}}icons/favicon-16x16.png"
10
+ link rel="icon" sizes="192x192" type="image/png" href="{{include_prefix}}icons/android-chrome-192x192.png"
11
+ link rel="icon" sizes="512x512" type="image/png" href="{{include_prefix}}icons/android-chrome-512x512.png"
12
+ link rel="apple-touch-icon" sizes="180x180" type="image/png" href="{{include_prefix}}icons/apple-touch-icon.png"
13
+ |
14
+ {{#if canonical}}
15
+ <link rel="canonical" href="{{include_prefix}}{{canonical}}" />
16
+ {{/if}}
17
+ {{#if prev_link}}
18
+ <link rel="prev" href="{{include_prefix}}{{prev_link}}" />
19
+ {{/if}}
20
+ {{#if next_link}}
21
+ <link rel="next" href="{{include_prefix}}{{next_link}}" />
22
+ {{/if}}
23
+ {{#if site_url}}
24
+ <link rel="alternate" type="application/atom+xml" href="{{include_prefix}}/feed.atom" title="All photos">
25
+ {{/if}}
26
+ {{#if open_graph}}
27
+ <meta property="og:type" content="website" />
28
+ <meta property="og:title" content="{{open_graph.title}}" />
29
+ <meta property="og:description" content="{{open_graph.description}}" />
30
+ <meta property="og:image" content="{{open_graph.image.url}}" />
31
+ <meta property="og:image:type" content="image/jpeg" />
32
+ {{#if open_graph.image.alt}}<meta property="og:image:alt" content="{{open_graph.image.alt}}" />{{/if}}
33
+ <meta property="og:image:width" content="{{open_graph.image.width}}" />
34
+ <meta property="og:image:height" content="{{open_graph.image.height}}" />
35
+ <meta property="og:url" content="{{open_graph.url}}" />
36
+ {{/if}}
@@ -0,0 +1,16 @@
1
+ nav
2
+ |
3
+ <input type="checkbox" id="menu_toggle" name="menu_toggle" />
4
+ <label for="menu_toggle">Menu</label>
5
+ {{#each nav_items as |nav_item|}}
6
+ {{#if nav_item.entries}}
7
+ <section>
8
+ <h2>{{nav_item.friendly_name}}</h2>
9
+ <ul>
10
+ {{#each nav_item.entries as |entry|}}
11
+ <li><a href="{{../../include_prefix}}{{entry.link}}" title="{{entry.photo_count}} {{#ifEqual entry.photo_count 1}}photo{{else}}photos{{/ifEqual}}">{{entry.friendly_name}}</a></li>
12
+ {{/each}}
13
+ </ul>
14
+ </section>
15
+ {{/if}}
16
+ {{/each}}
@@ -0,0 +1,17 @@
1
+ |
2
+ {{#if stream.previous}}
3
+ <a class="navigation_arrow left" href="{{../include_prefix}}{{stream.selector_path}}{{stream.previous.hash}}.html#photo">&laquo;</a>
4
+ {{/if}}
5
+ <div class="stamps">
6
+ {{#each stream.photos as |substream_photo|}}
7
+ <a href="{{../../include_prefix}}{{stream.selector_path}}{{substream_photo.hash}}.html#photo">
8
+ <img
9
+ class="stamp{{#ifEqual substream_photo.hash ../../photo.hash}} current{{/ifEqual}}"
10
+ src="{{../../include_prefix}}images/thumbnails/{{substream_photo.hash}}.{{substream_photo.file_name}}"
11
+ alt="{{substream_photo.title}}" />
12
+ </a>
13
+ {{/each}}
14
+ </div>
15
+ {{#if stream.next}}
16
+ <a class="navigation_arrow right" href="{{../include_prefix}}{{stream.selector_path}}{{stream.next.hash}}.html#photo">&raquo;</a>
17
+ {{/if}}
@@ -0,0 +1,29 @@
1
+ doctype xml
2
+ feed xmlns="http://www.w3.org/2005/Atom"
3
+ title Photography by #{site_metadata[:author_name]}
4
+ author
5
+ name #{site_metadata[:author_name]}
6
+ link rel="self" href=URI::join(Piccle.config.home_url, joined_selector, "feed.atom")
7
+ link rel="alternate" href=URI::join(Piccle.config.home_url, joined_selector, "index.html")
8
+ icon = URI::join(Piccle.config.home_url, "icons", "android-chrome-192x192.png")
9
+ updated = feed_update_time.strftime("%FT%T%:z")
10
+ id = URI::join(Piccle.config.home_url, joined_selector, "feed.atom")
11
+
12
+ - photos.map do |hash, photo|
13
+ entry
14
+ title = photo[:title] || "A photo by #{site_metadata[:author_name]}"
15
+ link rel="alternate" href=URI::join(Piccle.config.home_url, joined_selector, "#{photo[:hash]}.html")
16
+ id = URI::join(Piccle.config.home_url, joined_selector, "#{photo[:hash]}.html")
17
+ updated = photo[:created_at].strftime("%FT%T%:z")
18
+ content type="html"
19
+ == Piccle::TemplateHelpers.escape_html
20
+ - if photo[:title]
21
+ h1 = photo[:title]
22
+ img src=URI::join(Piccle.config.home_url, "/images/photos/", "#{photo[:hash]}.#{URI::DEFAULT_PARSER.escape(photo[:file_name])}")
23
+ - if photo[:description]
24
+ p = photo[:description]
25
+ - if photo[:has_location]
26
+ p #{[photo[:city], photo[:state], photo[:country]].compact.join(", ")}.
27
+ - if photo[:taken_at]
28
+ p Taken #{photo[:taken_at].strftime("%-d %B, %Y.")}
29
+
@@ -0,0 +1,36 @@
1
+ doctype html
2
+ html
3
+ == Piccle::TemplateHelpers.render_partial "header"
4
+
5
+ body.photos_index
6
+ header
7
+ |
8
+ {{#if selector}}
9
+ <h1><a href="{{include_prefix}}index.html">Photography by {{site_metadata.author_name}}</a></h1>
10
+ {{else}}
11
+ <h1>Photography by {{site_metadata.author_name}}</a></h1>
12
+ {{/if}}
13
+ == Piccle::TemplateHelpers.render_partial "breadcrumbs"
14
+ main
15
+ == Piccle::TemplateHelpers.render_partial "navigation"
16
+
17
+ .photos
18
+ |
19
+ {{#each photos as |photo|}}
20
+ {{#if (lookup ../event_ends photo.hash)}}
21
+ {{#with (lookup ../event_ends hash)}}
22
+ <p class="event_block event_end"><a href="{{../include_prefix}}{{join selector '/'}}/index.html">{{name}}</a></p>
23
+ {{/with}}
24
+ {{/if}}
25
+ <a href="{{../include_prefix}}{{../selector_path}}{{photo.hash}}.html#photo">
26
+ <img class="thumbnail" src="{{../include_prefix}}images/thumbnails/{{photo.hash}}.{{photo.file_name}}" alt="{{photo.title}}" />
27
+ </a>
28
+ {{#if (lookup ../event_starts photo.hash)}}
29
+ {{#with (lookup ../event_starts hash)}}
30
+ <p class="event_block event_start"><a href="{{../include_prefix}}{{join selector '/'}}/index.html">{{name}}</a></p>
31
+ {{/with}}
32
+ {{/if}}
33
+ {{/each}}
34
+
35
+ == Piccle::TemplateHelpers.render_partial "footer"
36
+
@@ -0,0 +1,64 @@
1
+ == Piccle::TemplateHelpers.render_partial "header"
2
+
3
+ body.photo_show
4
+ header
5
+ h1
6
+ a href="{{include_prefix}}index.html" Photography by {{site_metadata.author_name}}
7
+ == Piccle::TemplateHelpers.render_partial "breadcrumbs"
8
+ main#photo
9
+ h2
10
+ | {{photo.title}}
11
+ .photo_with_pagination
12
+ | {{#if prev_link}}
13
+ <a class="navigation_arrow" href="{{include_prefix}}{{prev_link}}#photo">&laquo;</a>
14
+ {{else}}
15
+ <span class="navigation_arrow">&nbsp;</span>
16
+ {{/if}}
17
+ img src="{{include_prefix}}images/photos/{{photo.hash}}.{{photo.file_name}}"
18
+ | {{#if next_link}}
19
+ <a class="navigation_arrow" href="{{include_prefix}}{{next_link}}#photo">&raquo;</a>
20
+ {{else}}
21
+ <span class="navigation_arrow">&nbsp;</span>
22
+ {{/if}}
23
+
24
+ p.description
25
+ | {{photo.description}}
26
+ p.settings
27
+ | {{#if camera_link}}
28
+ <a href="{{include_prefix}}{{camera_link.link}}">{{camera_link.friendly_name}}</a>{{/if}}{{#if photo.focal_length}}, {{photo.focal_length}}{{/if}}{{#if photo.aperture}}, f/{{photo.aperture}}{{/if}}{{#if photo.shutter_speed}}, {{photo.shutter_speed}}{{/if}}{{#if photo.iso}}, ISO {{photo.iso}}{{/if}}.
29
+ | {{#if photo.taken_at}}
30
+ <p class="date">
31
+ {{#if day_link}}<a href="{{include_prefix}}{{day_link.link}}">{{day_link.friendly_name}}</a>{{/if}}
32
+ {{#if month_link}}<a href="{{include_prefix}}{{month_link.link}}">{{month_link.friendly_name}}</a>, {{/if}}
33
+ {{#if year_link}}<a href="{{include_prefix}}{{year_link.link}}">{{year_link.friendly_name}}</a>.{{/if}}
34
+ </p>
35
+ {{/if}}
36
+ {{#if photo.has_location}}
37
+ <ul class="location">
38
+ {{#if city_link}}<li><a href="{{include_prefix}}{{city_link.link}}">{{city_link.friendly_name}}</a></li>{{/if}}
39
+ {{#if state_link}}<li><a href="{{include_prefix}}{{state_link.link}}">{{state_link.friendly_name}}</a></li>{{/if}}
40
+ {{#if country_link}}<li><a href="{{include_prefix}}{{country_link.link}}">{{country_link.friendly_name}}</a></li>{{/if}}
41
+ </ul>
42
+ {{/if}}
43
+
44
+ | {{#if keywords}}
45
+ <div class="keywords">
46
+ {{#each keywords as |keyword|}}
47
+ <a href="{{../include_prefix}}{{keyword.link}}">{{keyword.friendly_name}}</a>
48
+ {{/each}}
49
+ </div>
50
+ {{/if}}
51
+
52
+ .streams
53
+ |
54
+ {{#each substreams as |stream|}}
55
+ <section>
56
+ <h2>{{stream.title}}</h2>
57
+ <div class="stream">
58
+ == Piccle::TemplateHelpers.render_partial "substream"
59
+ |
60
+ </div>
61
+ </section>
62
+ {{/each}}
63
+
64
+ == Piccle::TemplateHelpers.render_partial "footer"