Compare commits
10 commits
794752592e
...
82276d6a48
| Author | SHA1 | Date | |
|---|---|---|---|
| 82276d6a48 | |||
| 4540c93d6e | |||
| a14f206a2a | |||
| 27fb1b7a86 | |||
| 57e935ec00 | |||
| ef937ca0eb | |||
| 7e2010efb0 | |||
| 3725dc17d0 | |||
| 54bf81b54d | |||
| b6164295f3 |
519
assets/app.css
|
|
@ -1,55 +1,155 @@
|
|||
/*
|
||||
Main CSS file for madr.se
|
||||
If you have any questions regarding the CSS, feel free
|
||||
to contact me: yttan at madr dot se
|
||||
|
||||
Table of contents, 7-1 inspired
|
||||
|
||||
1. Base
|
||||
2. Components
|
||||
3. Layout
|
||||
4. Pages
|
||||
5. Themes
|
||||
6. Vendors
|
||||
7. Shame
|
||||
*/
|
||||
|
||||
/* === 1. Base === */
|
||||
|
||||
/*
|
||||
Normalize and resets.
|
||||
Only properties, element and attribute selectors are allowed in this
|
||||
section.
|
||||
|
||||
All dynamic values that should change according to user preferences
|
||||
(dark or light color mode, reduced motion etc) and agent abilities
|
||||
(small handheld screen, big desktop screen) are handled by properties
|
||||
when appliable.
|
||||
|
||||
This is to avoid redeclarating CSS rules.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--color: #fff;
|
||||
--bgcolor: #201;
|
||||
--box-padding: 12px;
|
||||
/* colors, dark mode default */
|
||||
--color: hsl(0 0 90%);
|
||||
--bgcolor: hsl(180 75% 6%);
|
||||
--tree-item-accent-color: dimgrey;
|
||||
--panel-bg-color: hsla(0 0 50% / 0.16);
|
||||
--monospace-color: springgreen;
|
||||
--monospace-color-inline: seagreen;
|
||||
--a-color: gold;
|
||||
|
||||
/* typography, mobile first */
|
||||
--base-font-size: 1.33em;
|
||||
--page-title-font-size: 2em;
|
||||
--tree-font-size: 0.85em;
|
||||
--section-heading-lv2-font-size: 1.5em;
|
||||
--section-heading-lv3-font-size: 1.2em;
|
||||
--section-heading-lv4-font-size: 1em;
|
||||
--system-serif-fonts: Cambria, Cochin, Georgia, Times, "Times New Roman",
|
||||
serif;
|
||||
--system-sansserif-fonts: apple-system, system-ui, BlinkMacSystemFont,
|
||||
Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif;
|
||||
--monospace-fonts: "JetBrains mono", monaco, menlo, meslo, "Courier New",
|
||||
Courier, monospace;
|
||||
|
||||
/* whitespace */
|
||||
--gap-sm: 0.5em;
|
||||
--gap-md: 1em;
|
||||
--gap-lg: 3em;
|
||||
|
||||
/* transitions */
|
||||
--animation-duration: 0.5s;
|
||||
|
||||
/* dimensions and aspect ratios */
|
||||
--map-ratio: 1;
|
||||
|
||||
@media (min-width: 666px) {
|
||||
--base-font-size: 1.66em;
|
||||
--tree-font-size: 0.67em;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
color: var(--color);
|
||||
background-color: var(--bgcolor);
|
||||
background-image: linear-gradient(#000, #201 333px);
|
||||
font:
|
||||
normal small/1.5 apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
sans-serif;
|
||||
font: normal var(--base-font-size) / 1.5 var(--system-sansserif-fonts);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
|
||||
> footer > p {
|
||||
margin-top: var(--gap-lg);
|
||||
color: #666;
|
||||
font-size: 0.66em;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--a-color);
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-size: 1.2em;
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.1;
|
||||
font-family: serif;
|
||||
font-family: var(--system-serif-fonts);
|
||||
}
|
||||
|
||||
main {
|
||||
background-color: #000;
|
||||
margin: 0 auto;
|
||||
max-width: 40em;
|
||||
box-sizing: border-box;
|
||||
padding: 3em;
|
||||
h1 {
|
||||
margin: 0.5em 0;
|
||||
line-height: 0.95;
|
||||
font-size: var(--page-title-font-size);
|
||||
|
||||
@media (min-width: 666px) {
|
||||
text-transform: lowercase;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
text-align: right;
|
||||
line-height: 0.9;
|
||||
color: var(--tree-item-accent-color);
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: 2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--section-heading-lv2-font-size);
|
||||
border-bottom: 3px solid var(--panel-bg-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--section-heading-lv3-font-size);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--section-heading-lv4-font-size);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 2em 0;
|
||||
background-color: #022;
|
||||
color: var(--monospace-color);
|
||||
overflow-y: auto;
|
||||
padding: 0.33em;
|
||||
box-shadow: 4px 4px 0 #333;
|
||||
padding: 0.66em;
|
||||
box-shadow: 4px 4px 0 var(--panel-bg-color);
|
||||
position: relative;
|
||||
line-height: 1.2;
|
||||
font-size: 0.8em;
|
||||
|
||||
> button {
|
||||
font-size: 0.75em;
|
||||
position: absolute;
|
||||
top: 0.25em;
|
||||
right: 0.25em;
|
||||
|
|
@ -57,28 +157,125 @@ pre {
|
|||
}
|
||||
|
||||
code {
|
||||
font-family: "JetBrains mono", monaco, menlo, meslo, "Courier New", Courier,
|
||||
monospace;
|
||||
font-family: var(--monospace-fonts);
|
||||
|
||||
&.inline {
|
||||
color: var(--monospace-color-inline);
|
||||
background: #f3f3f3;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
position: relative;
|
||||
|
||||
& > h2 {
|
||||
background: #000;
|
||||
padding: 1em 0;
|
||||
background: var(--bgcolor);
|
||||
color: var(--color);
|
||||
padding: 0.5em 0.25em;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skiplink {
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
article {
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
text-align: center;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
background-color: var(--background-color-l);
|
||||
padding: 0.25em;
|
||||
font-size: 0.8em;
|
||||
border: 1px solid rgb(128, 128, 128, 0.5);
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--background-color-ll);
|
||||
text-transform: uppercase;
|
||||
color: var(--em-color);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
li {
|
||||
color: var(--em-color);
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
li:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: var(--em-color);
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2;
|
||||
font-style: italic;
|
||||
border-left: 5px solid var(--background-color-l);
|
||||
margin: 1em 1em 1em 0;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
blockquote p::after,
|
||||
blockquote p::before {
|
||||
content: '"';
|
||||
}
|
||||
|
||||
/* === /Base === */
|
||||
/* === 2. Components === */
|
||||
|
||||
/*
|
||||
Use kebab case named classes to identify components, and nesting
|
||||
to group subcomponents.
|
||||
|
||||
Element selectors are preferred as subcomponents, due to the simple
|
||||
nature of this site. As a general rule though, classes are the most
|
||||
versatile.
|
||||
*/
|
||||
|
||||
.home-search,
|
||||
.profiles {
|
||||
font-size: var(--tree-font-size);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
top: -5em;
|
||||
transition: top 0.4s ease-out;
|
||||
padding: 0.25em 0.5em;
|
||||
|
||||
&:focus {
|
||||
top: 1em;
|
||||
}
|
||||
left: -999em;
|
||||
}
|
||||
|
||||
.flx {
|
||||
|
|
@ -87,32 +284,94 @@ section {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.interactive-map {
|
||||
aspect-ratio: var(--map-ratio);
|
||||
}
|
||||
|
||||
.home-h1 {
|
||||
font-size: 1.33em;
|
||||
}
|
||||
|
||||
.list-link {
|
||||
&::after {
|
||||
content: " →";
|
||||
}
|
||||
}
|
||||
|
||||
.feed-link {
|
||||
&::after {
|
||||
content: " ↗";
|
||||
}
|
||||
}
|
||||
|
||||
.skiplink {
|
||||
position: absolute;
|
||||
top: -5em;
|
||||
transition: top var(--animation-duration) ease-out;
|
||||
padding: 0.25em 0.5em;
|
||||
|
||||
&:focus {
|
||||
top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.tree {
|
||||
color: crimson;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 1em auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
gap: 0.66em;
|
||||
font-size: var(--tree-font-size);
|
||||
|
||||
> * {
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 5px;
|
||||
> li {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr auto;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
gap: 0.5em;
|
||||
padding: 0.75em;
|
||||
min-height: 50px;
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
border: 1px solid rgba(192, 192, 192, 0.1);
|
||||
|
||||
&:focus-within {
|
||||
background-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
|
||||
> small {
|
||||
opacity: 0.66;
|
||||
font-family: var(--monospace-fonts);
|
||||
font-size: 0.66em;
|
||||
}
|
||||
}
|
||||
|
||||
> :nth-child(even) {
|
||||
text-align: right;
|
||||
> .article {
|
||||
--tree-item-accent-color: rebeccapurple;
|
||||
}
|
||||
|
||||
> .album {
|
||||
--tree-item-accent-color: goldenrod;
|
||||
}
|
||||
|
||||
> .link {
|
||||
--tree-item-accent-color: honeydew;
|
||||
}
|
||||
|
||||
> .events {
|
||||
--tree-item-accent-color: firebrick;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
color: var(--color);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
|
|
@ -122,45 +381,35 @@ section {
|
|||
}
|
||||
|
||||
.landing {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 3em;
|
||||
padding: 2em 0;
|
||||
box-sizing: border-box;
|
||||
gap: 3em;
|
||||
gap: 1.66em;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
display: block;
|
||||
margin: var(--gap-sm) 0;
|
||||
padding: var(--gap-sm);
|
||||
border: 1px solid rgb(128, 128, 128, 0.25);
|
||||
background-color: var(--panel-bg-color);
|
||||
border-radius: 0;
|
||||
|
||||
> li {
|
||||
> span {
|
||||
display: inline;
|
||||
|
||||
&:after {
|
||||
content: " /";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article {
|
||||
font-size: large;
|
||||
box-sizing: border-box;
|
||||
|
||||
> footer {
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
font-size: 0.8em;
|
||||
a {
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.months {
|
||||
grid-auto-flow: rows;
|
||||
display: grid;
|
||||
|
|
@ -168,7 +417,36 @@ section {
|
|||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5em;
|
||||
padding-left: 0;
|
||||
margin: 3em 0;
|
||||
margin: 2.75em 0;
|
||||
|
||||
> li {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.article {
|
||||
> div > p:first-child::first-letter {
|
||||
@media (min-width: 500px) {
|
||||
float: left;
|
||||
font-size: 7em;
|
||||
border: 8px double hsl(0 0 50%);
|
||||
padding: 0.1em;
|
||||
margin-right: 0.066em;
|
||||
font-style: normal;
|
||||
font-family: var(--system-serif-fonts);
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
font-style: italic;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
|
||||
> p {
|
||||
margin-top: 2.75em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.articles {
|
||||
|
|
@ -188,7 +466,7 @@ section {
|
|||
}
|
||||
}
|
||||
|
||||
date {
|
||||
time {
|
||||
border-top: 1px solid crimson;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
|
|
@ -199,3 +477,104 @@ section {
|
|||
margin-top: 3em;
|
||||
}
|
||||
}
|
||||
|
||||
.brutal-legend {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
> p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> img {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.profiles {
|
||||
display: flex;
|
||||
gap: 1.66em;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
> li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* === /Components === */
|
||||
/* === 3. Layout === */
|
||||
/*
|
||||
Containers and wrappers for components.
|
||||
Only class selectors allowed, with the following element selectors as
|
||||
exceptions: aside, body, footer, header, main and nav.
|
||||
*/
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: 33em;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
/* === /Layout === */
|
||||
/* === 4. Pages === */
|
||||
/*
|
||||
Styles that should only apply to certain pages.
|
||||
*/
|
||||
/* === /Pages === */
|
||||
/* === 5. Themes === */
|
||||
/*
|
||||
Styles to create user-customized themes.
|
||||
|
||||
This section adapts the design to the following user preferences:
|
||||
Color theme, reduced motion
|
||||
*/
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--color: #000;
|
||||
--bgcolor: #fff;
|
||||
--a-color: blue;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
:root {
|
||||
--animation-duration: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
:root {
|
||||
--map-ratio: 3 / 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
:root {
|
||||
--page-title-font-size: 4em;
|
||||
}
|
||||
}
|
||||
/* === /Themes === */
|
||||
/* === 6. Vendors === */
|
||||
/*
|
||||
Styles belonging to third-party components.
|
||||
*/
|
||||
|
||||
.footnotes-list {
|
||||
color: var(--aside-color);
|
||||
font-size: 80%;
|
||||
}
|
||||
/* === /Vendors === */
|
||||
|
||||
/* === 7. Shame === */
|
||||
/*
|
||||
Styles necessary for specifity issues and for cutting corners
|
||||
(breaking the rules in short terms in waiting for an opportunity
|
||||
to rewrite or fix a problem for good).
|
||||
|
||||
madr.se has no reason to feel ashamed. Yet.
|
||||
*/
|
||||
/* === /Shame === */
|
||||
|
|
|
|||
BIN
assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -26,7 +26,7 @@ config :esbuild,
|
|||
version: "0.17.11",
|
||||
mse25: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --external:/css/*),
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/bl/* --external:/images/* --external:/*.{vcf,png,ico,pdf}),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
defmodule Mse25.Directus do
|
||||
@moduledoc """
|
||||
Simple Directus client, utilizing Req to do CRUD
|
||||
operations.
|
||||
|
||||
Currently, this client only read data, and supports
|
||||
various ways of filtering data.
|
||||
|
||||
It is by no means generic, since fieldsets are not
|
||||
agnostic. It may however be used as a base to create
|
||||
a more generic client implementation in Elixir.
|
||||
|
||||
Directus documentation:
|
||||
https://docs.directus.io/
|
||||
"""
|
||||
|
||||
@draft_filter "filter[status][_eq]=published"
|
||||
|
||||
def get_article(slug) do
|
||||
|
|
@ -15,7 +30,8 @@ defmodule Mse25.Directus do
|
|||
"slug",
|
||||
"title",
|
||||
"date_updated",
|
||||
"pubDate"
|
||||
"pubDate",
|
||||
"contents"
|
||||
],
|
||||
","
|
||||
)
|
||||
|
|
@ -27,21 +43,30 @@ defmodule Mse25.Directus do
|
|||
end
|
||||
|
||||
def get_album(externalId) do
|
||||
get_item(
|
||||
case get_item(
|
||||
:albums,
|
||||
externalId,
|
||||
[
|
||||
"purchased_at",
|
||||
"album",
|
||||
"year",
|
||||
"youtubeId",
|
||||
"externalId",
|
||||
"cover",
|
||||
"*",
|
||||
"songs.title",
|
||||
"songs.artist.name"
|
||||
]
|
||||
|> Enum.join(",")
|
||||
)
|
||||
) do
|
||||
{:ok,
|
||||
data = %{
|
||||
"album" => album,
|
||||
"year" => year,
|
||||
"songs" => [%{"artist" => %{"name" => artist}} | _]
|
||||
}} ->
|
||||
{:ok,
|
||||
data
|
||||
|> Map.put("artist", artist)
|
||||
|> Map.put("summary", "#{artist} - #{album} (#{to_string(year)})")}
|
||||
|
||||
not_found ->
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
||||
def get_albums!(options \\ []) do
|
||||
|
|
@ -51,25 +76,33 @@ defmodule Mse25.Directus do
|
|||
"fields=" <>
|
||||
Enum.join(
|
||||
[
|
||||
"purchased_at",
|
||||
"album",
|
||||
"year",
|
||||
"externalId",
|
||||
"cover.filename_download",
|
||||
"cover.width",
|
||||
"cover.height",
|
||||
"*",
|
||||
"songs.title",
|
||||
"songs.artist.name"
|
||||
],
|
||||
","
|
||||
)
|
||||
]
|
||||
|> annual?(:albums, options)
|
||||
|> query_params_string(options, :brutal_legends)
|
||||
|
||||
get("/albums?" <> params)
|
||||
|> Enum.map(fn m = %{"songs" => [%{"artist" => %{"name" => a}} | _]} ->
|
||||
Map.put(m, "artist", a)
|
||||
end)
|
||||
|> Enum.map(
|
||||
fn m = %{
|
||||
"album" => album,
|
||||
"year" => year,
|
||||
"songs" => [%{"artist" => %{"name" => artist}} | _],
|
||||
"purchased_at" => purchased_at
|
||||
} ->
|
||||
m
|
||||
|> Map.put("artist", artist)
|
||||
|> Map.put(
|
||||
"purchase_year",
|
||||
String.slice(purchased_at, 0..3)
|
||||
)
|
||||
|> Map.put("summary", "#{artist} - #{album} (#{to_string(year)})")
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
def get_event(slug) do
|
||||
|
|
@ -101,6 +134,7 @@ defmodule Mse25.Directus do
|
|||
"fields=" <>
|
||||
Enum.join(
|
||||
[
|
||||
"id",
|
||||
"title",
|
||||
"lead",
|
||||
"slug",
|
||||
|
|
@ -108,8 +142,11 @@ defmodule Mse25.Directus do
|
|||
"category",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"contents",
|
||||
"date_created",
|
||||
"bands.artists_id.name",
|
||||
"mia.artists_id.name"
|
||||
"mia.artists_id.name",
|
||||
"location.*"
|
||||
],
|
||||
","
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
defmodule Mse25.EventHelpers do
|
||||
def bandlist(bands) do
|
||||
bands
|
||||
|> Enum.map(fn b -> b["artists_id"]["name"] end)
|
||||
|> Enum.join(", ")
|
||||
|> String.replace(~r/, ([^,]+?)$/, " och \\1")
|
||||
end
|
||||
|
||||
def hilights?(%{"bands" => bands, "category" => category}) do
|
||||
_festival_band?(bands, category)
|
||||
end
|
||||
|
|
@ -30,4 +23,18 @@ defmodule Mse25.EventHelpers do
|
|||
def _festival_band?(_b, _c) do
|
||||
false
|
||||
end
|
||||
|
||||
def bandlist(bands) do
|
||||
bands
|
||||
|> Enum.map(fn b -> b["artists_id"]["name"] end)
|
||||
|> Enum.join(", ")
|
||||
|> String.replace(~r/, ([^,]+?)$/, " och \\1")
|
||||
end
|
||||
|
||||
def rdfa_bandlist(bands) do
|
||||
bands
|
||||
|> Enum.map(fn b -> "<span property=\"performer\">#{b["artists_id"]["name"]}</span>" end)
|
||||
|> Enum.join(", ")
|
||||
|> String.replace(~r/, ([^,]+?)$/, " och \\1")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,23 +1,35 @@
|
|||
defmodule Mse25.Timeline do
|
||||
alias Mse25.Directus
|
||||
|
||||
def archive() do
|
||||
@almost_infinity 9999
|
||||
|
||||
def archive(limit \\ @almost_infinity) do
|
||||
items =
|
||||
Task.await_many([
|
||||
Task.async(fn -> Directus.get_albums!() end),
|
||||
Task.async(fn -> Directus.get_articles!(limit: 9999) end),
|
||||
Task.async(fn -> Directus.get_links!(limit: 9999) end),
|
||||
Task.async(fn -> Directus.get_events!(limit: 9999) end)
|
||||
Task.async(fn -> Directus.get_articles!(limit: limit) end),
|
||||
Task.async(fn -> Directus.get_links!(limit: limit) end),
|
||||
Task.async(fn -> Directus.get_events!(limit: limit) end)
|
||||
])
|
||||
|
||||
archive =
|
||||
items
|
||||
|> List.flatten()
|
||||
|> Enum.sort_by(&sort_key/1)
|
||||
|> Enum.reverse()
|
||||
|> Enum.take(limit)
|
||||
|> Enum.map(&categorize/1)
|
||||
|
||||
{:ok, %{archive: archive}}
|
||||
end
|
||||
|
||||
def annual(year) do
|
||||
items =
|
||||
Task.await_many([
|
||||
Task.async(fn -> Directus.get_albums!(limit: 9999, year: year) end),
|
||||
Task.async(fn -> Directus.get_articles!(limit: 9999, year: year) end),
|
||||
Task.async(fn -> Directus.get_links!(limit: 9999, year: year) end),
|
||||
Task.async(fn -> Directus.get_events!(limit: 9999, year: year) end)
|
||||
Task.async(fn -> Directus.get_albums!(limit: @almost_infinity, year: year) end),
|
||||
Task.async(fn -> Directus.get_articles!(limit: @almost_infinity, year: year) end),
|
||||
Task.async(fn -> Directus.get_links!(limit: @almost_infinity, year: year) end),
|
||||
Task.async(fn -> Directus.get_events!(limit: @almost_infinity, year: year) end)
|
||||
])
|
||||
|
||||
counts =
|
||||
|
|
@ -40,10 +52,20 @@ defmodule Mse25.Timeline do
|
|||
def search(query) do
|
||||
items =
|
||||
Task.await_many([
|
||||
Task.async(fn -> Directus.get_articles!(limit: 9999, query: query) end),
|
||||
Task.async(fn -> Directus.get_links!(limit: 9999, query: query) end),
|
||||
Task.async(fn -> Directus.get_events!(limit: 9999, query: query) end)
|
||||
Task.async(fn -> Directus.get_articles!(limit: @almost_infinity, query: query) end),
|
||||
Task.async(fn -> Directus.get_links!(limit: @almost_infinity, query: query) end),
|
||||
Task.async(fn -> Directus.get_events!(limit: @almost_infinity, query: query) end)
|
||||
])
|
||||
|
||||
results =
|
||||
items
|
||||
|> List.flatten()
|
||||
|> Enum.sort_by(&sort_key/1)
|
||||
|> Enum.map(&categorize/1)
|
||||
# |> Enum.group_by(fn item -> sort_key(item) |> String.slice(5..6) end)
|
||||
|> Enum.reverse()
|
||||
|
||||
{:ok, %{query: query, results: results, count: length(results)}}
|
||||
end
|
||||
|
||||
defp sort_key(%{"pubDate" => date}), do: date
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ defmodule Mse25Web do
|
|||
those modules here.
|
||||
"""
|
||||
|
||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||
def static_paths,
|
||||
do: ~w(assets fonts images bl favicon.ico robots.txt aey.vcf app.css cv)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
|
|
@ -26,13 +27,6 @@ defmodule Mse25Web do
|
|||
# Import common connection and controller functions to use in pipelines
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -49,23 +43,6 @@ defmodule Mse25Web do
|
|||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView,
|
||||
layout: {Mse25Web.Layouts, :app}
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
|
@ -81,16 +58,10 @@ defmodule Mse25Web do
|
|||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
# Core UI components and translation
|
||||
import Mse25Web.CoreComponents
|
||||
import Mse25Web.Gettext
|
||||
|
||||
# Shortcut for generating JS commands
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,676 +1,2 @@
|
|||
defmodule Mse25Web.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as modals, tables, and
|
||||
forms. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
The default components use Tailwind CSS, a utility-first CSS framework.
|
||||
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
|
||||
how to customize them or feel free to swap in another framework altogether.
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
import Mse25Web.Gettext
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
This is a modal.
|
||||
</.modal>
|
||||
|
||||
JS commands may be passed to the `:on_cancel` to configure
|
||||
the closing/cancel event, for example:
|
||||
|
||||
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
|
||||
This is another modal.
|
||||
</.modal>
|
||||
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :on_cancel, JS, default: %JS{}
|
||||
slot :inner_block, required: true
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="relative z-50 hidden"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
|
||||
<div
|
||||
class="fixed inset-0 overflow-y-auto"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
aria-describedby={"#{@id}-description"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex min-h-full items-center justify-center">
|
||||
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class={[
|
||||
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
|
||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
||||
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
|
||||
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
|
||||
<%= @title %>
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-5"><%= msg %></p>
|
||||
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
|
||||
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error")}
|
||||
phx-connected={hide("#client-error")}
|
||||
hidden
|
||||
>
|
||||
<%= gettext("Attempting to reconnect") %>
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error")}
|
||||
phx-connected={hide("#server-error")}
|
||||
hidden
|
||||
>
|
||||
<%= gettext("Hang in there while we get back on track") %>
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a simple form.
|
||||
|
||||
## Examples
|
||||
|
||||
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label="Email"/>
|
||||
<.input field={@form[:username]} label="Username" />
|
||||
<:actions>
|
||||
<.button>Save</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
"""
|
||||
attr :for, :any, required: true, doc: "the data structure for the form"
|
||||
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
|
||||
doc: "the arbitrary HTML attributes to apply to the form tag"
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :actions, doc: "the slot for form actions, such as a submit button"
|
||||
|
||||
def simple_form(assigns) do
|
||||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="mt-10 space-y-8 bg-white">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||
<%= render_slot(action, f) %>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" class="ml-2">Send!</.button>
|
||||
"""
|
||||
attr :type, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
|
||||
"text-sm font-semibold leading-6 text-white active:text-white/80",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as hidden and radio,
|
||||
are best written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
range search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
|
||||
{@rest}
|
||||
/>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a label.
|
||||
"""
|
||||
attr :for, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a generic error message.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id"><%= user.id %></:col>
|
||||
<:col :let={user} label="username"><%= user.username %></:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
|
||||
<table class="w-[40rem] mt-11 sm:w-full">
|
||||
<thead class="text-sm text-left leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
||||
<th :if={@action != []} class="relative p-0 pb-4">
|
||||
<span class="sr-only"><%= gettext("Actions") %></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id={@id}
|
||||
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
||||
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
|
||||
>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
|
||||
<td
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div class="block py-4 pr-6">
|
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td :if={@action != []} class="relative w-14 p-0">
|
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span
|
||||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title"><%= @post.title %></:item>
|
||||
<:item title="Views"><%= @post.views %></:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-zinc-700"><%= render_slot(item) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a back navigation link.
|
||||
|
||||
## Examples
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
"""
|
||||
attr :navigate, :any, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="mt-16">
|
||||
<.link
|
||||
navigate={@navigate}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
time: 300,
|
||||
transition:
|
||||
{"transition-all transform ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition:
|
||||
{"transition-all transform ease-in duration-200",
|
||||
"opacity-100 translate-y-0 sm:scale-100",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||
)
|
||||
end
|
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.show(to: "##{id}")
|
||||
|> JS.show(
|
||||
to: "##{id}-bg",
|
||||
time: 300,
|
||||
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
|
||||
)
|
||||
|> show("##{id}-container")
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
|> JS.focus_first(to: "##{id}-content")
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}, id) do
|
||||
js
|
||||
|> JS.hide(
|
||||
to: "##{id}-bg",
|
||||
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
|
||||
)
|
||||
|> hide("##{id}-container")
|
||||
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
|> JS.pop_focus()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(Mse25Web.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(Mse25Web.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,14 +1,114 @@
|
|||
defmodule Mse25Web.Layouts do
|
||||
@moduledoc """
|
||||
This module holds different layouts used by your application.
|
||||
|
||||
See the `layouts` directory for all templates available.
|
||||
The "root" layout is a skeleton rendered as part of the
|
||||
application router. The "app" layout is set as the default
|
||||
layout on both `use Mse25Web, :controller` and
|
||||
`use Mse25Web, :live_view`.
|
||||
"""
|
||||
use Mse25Web, :html
|
||||
|
||||
@url "https://madr.se"
|
||||
@list_views ["webblogg", "delningar", "evenemang"]
|
||||
|
||||
embed_templates "layouts/*"
|
||||
|
||||
def canonical(%{year: _, conn: %{path_info: path}}) do
|
||||
~s"""
|
||||
<link rel="canonical" href="#{@url}/#{Enum.join(path, "/")}" />
|
||||
"""
|
||||
end
|
||||
|
||||
def canonical(_) do
|
||||
""
|
||||
end
|
||||
|
||||
def opengraph(%{heading: title, lead: lead, conn: %{path_info: path}}) do
|
||||
~s"""
|
||||
<meta property="og:title" content="#{title}" />
|
||||
<meta property="og:description" content="#{lead}" />
|
||||
<meta property="og:type" content="event" />
|
||||
<meta property="og:url" content="#{@url}/#{Enum.join(path, "/")}" />
|
||||
<meta property="og:site_name" content="madr.se" />
|
||||
"""
|
||||
end
|
||||
|
||||
def opengraph(%{heading: title, conn: %{path_info: path}}) do
|
||||
~s"""
|
||||
<meta property="og:title" content="#{title}" />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="#{@url}/#{Enum.join(path, "/")}" />
|
||||
<meta property="og:site_name" content="madr.se" />
|
||||
"""
|
||||
end
|
||||
|
||||
def opengraph(%{page_title: title, conn: %{path_info: path}}) do
|
||||
~s"""
|
||||
<meta property="og:title" content="#{title}" />
|
||||
<meta property="og:type" content="page" />
|
||||
<meta property="og:url" content="#{@url}/#{Enum.join(path, "/")}" />
|
||||
<meta property="og:site_name" content="madr.se" />
|
||||
"""
|
||||
end
|
||||
|
||||
def robots(%{conn: %{path_info: [first | []]}}) do
|
||||
case Integer.parse(first) do
|
||||
:error ->
|
||||
case Enum.member?(@list_views, first) do
|
||||
true ->
|
||||
~s"""
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
"""
|
||||
|
||||
false ->
|
||||
~s"""
|
||||
<meta name="robots" content="index,follow" />
|
||||
"""
|
||||
end
|
||||
|
||||
{_i, _d} ->
|
||||
~s"""
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
def robots(%{conn: %{path_info: [_p, _c]}}) do
|
||||
~s"""
|
||||
<meta name="robots" content="index,follow" />
|
||||
"""
|
||||
end
|
||||
|
||||
def robots(_) do
|
||||
~s"""
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
"""
|
||||
end
|
||||
|
||||
def breadcrumbs(nodes) do
|
||||
breadcrumbs([], "", 1, nodes)
|
||||
end
|
||||
|
||||
def breadcrumbs(seen, _path, _index, []) do
|
||||
Enum.reverse(seen)
|
||||
end
|
||||
|
||||
def breadcrumbs(seen, path, index, [{slug, name} | nodes]) do
|
||||
breadcrumbs(
|
||||
[{index + 1, {path <> "/" <> to_string(slug), name}} | seen],
|
||||
path <> "/" <> to_string(slug),
|
||||
index + 1,
|
||||
nodes
|
||||
)
|
||||
end
|
||||
|
||||
def breadcrumbs(seen, path, index, [{slug, name, custom_prefix} | nodes]) do
|
||||
breadcrumbs(
|
||||
[{index + 1, {custom_prefix <> "/" <> to_string(slug), name}} | seen],
|
||||
path <> "/" <> to_string(slug),
|
||||
index + 1,
|
||||
nodes
|
||||
)
|
||||
end
|
||||
|
||||
def show_interactive_event_map?(assigns) do
|
||||
Map.has_key?(assigns, :events)
|
||||
end
|
||||
|
||||
def show_footer?(%{heading: "Kolofon"}), do: false
|
||||
|
||||
def show_footer?(%{}), do: true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,17 +1,37 @@
|
|||
<main>
|
||||
<a href="#content" class="skiplink">Hoppa till innehållet</a>
|
||||
|
||||
<nav>
|
||||
<span class="sr-only">Du är här:</span>
|
||||
<span class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<span itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</span>
|
||||
<%= for {index, {parent_slug, parent_name}} <- breadcrumbs(@breadcrumbs) do %>
|
||||
<span class="sr-only">></span>
|
||||
<span itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href={parent_slug}>
|
||||
<span itemprop="name"><%= parent_name %></span>
|
||||
</a>
|
||||
<meta itemprop="position" content={index} />
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</nav>
|
||||
<main id="content">
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
<!--
|
||||
<%= if show_footer?(assigns) do %>
|
||||
<footer>
|
||||
<p>
|
||||
<a href="https://madr.se" rel="home">madr.se</a>
|
||||
av Anders Englöf Ytterström, sedan 2006. <a href="/colophon">Kolofon</a>.
|
||||
<p vocab="https://schema.org/" typeof="Person">
|
||||
<a href="https://madr.se" property="url">madr.se</a>
|
||||
är <span property="name">Anders Englöf Ytterström</span>s hemsida. Anders är <span property="jobTitle">webbutvecklare</span>, linuxentusiast, ljudtekniker och hårdrockare, bosatt i <span
|
||||
property="address"
|
||||
typeof="PostalAddress"
|
||||
><span property="addressLocality">Borlänge</span> (<span property="addressRegion">Dalarna</span>)</span>.
|
||||
Läs <a href="/colophon">kolofonen</a>.
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/madr" rel="external">Github</a></li>
|
||||
<li><a href="https://linkedin.com/anders-ytterstrom" rel="external">LinkedIn</a></li>
|
||||
<li><a href="https://discogs.com/madr" rel="external">Discogs</a></li>
|
||||
<li><a href="https://songkick.com/madr" rel="external">Songkick</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
-->
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,32 @@
|
|||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title><%= assigns.page_title || "Anders Englöf Ytterström" %> | madr.se</title>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<title><%= assigns[:page_title] || "Anders Englöf Ytterström" %> | madr.se</title>
|
||||
<meta name="robots" content="noimageai" />
|
||||
<meta name="robots" content="noai" />
|
||||
<meta name="author" content="Anders Englöf Ytterström" />
|
||||
<link rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<link
|
||||
href="/prenumerera.xml"
|
||||
type="application/rss+xml"
|
||||
rel="alternate"
|
||||
title="madr.se: inlägg, evenemang, delningar"
|
||||
/>
|
||||
<%= canonical(assigns) |> raw %>
|
||||
<%= opengraph(assigns) |> raw %>
|
||||
<%= robots(assigns) |> raw %>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<body>
|
||||
<%= @inner_content %>
|
||||
<script src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
<%= if show_interactive_event_map?(assigns) do %>
|
||||
<script src="/event-map.js">
|
||||
</script>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
144
lib/mse25_web/controllers/feed_controller.ex
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
defmodule Mse25Web.FeedController do
|
||||
use Mse25Web, :controller
|
||||
alias Mse25.Directus
|
||||
alias Mse25.Timeline
|
||||
plug :put_layout, false
|
||||
|
||||
def feed(conn, _params) do
|
||||
{:ok, %{archive: items}} = Timeline.archive(20)
|
||||
|
||||
text(
|
||||
conn |> put_resp_content_type("application/rss+xml"),
|
||||
items
|
||||
|> Mse25Web.FeedView.rss(conn.host)
|
||||
)
|
||||
end
|
||||
|
||||
def calendar(conn, _) do
|
||||
text(
|
||||
conn |> put_resp_content_type("text/calendar"),
|
||||
Directus.get_events!(upcoming: true, limit: 9999)
|
||||
|> Enum.map(fn %{
|
||||
"id" => id,
|
||||
"title" => title,
|
||||
"lead" => lead,
|
||||
"started_at" => starts_at,
|
||||
"ended_at" => ends_at,
|
||||
"date_created" => created_at,
|
||||
"location" => %{
|
||||
"name" => venue,
|
||||
"address" => region,
|
||||
"position" => %{
|
||||
"coordinates" => [lat, lng]
|
||||
}
|
||||
}
|
||||
} ->
|
||||
%{
|
||||
id: id,
|
||||
title: title,
|
||||
lead: lead,
|
||||
region: region,
|
||||
venue: venue,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
all_day?: true,
|
||||
updated_at: created_at |> String.slice(0..18) |> String.replace(~r/[-:]/, ""),
|
||||
created_at: created_at |> String.slice(0..18) |> String.replace(~r/[-:]/, ""),
|
||||
starts_at: String.replace(starts_at, "-", ""),
|
||||
ends_at: String.replace(ends_at, "-", "")
|
||||
}
|
||||
end)
|
||||
|> Mse25Web.FeedView.calendar()
|
||||
)
|
||||
end
|
||||
|
||||
def albums(conn, _) do
|
||||
json(
|
||||
conn,
|
||||
Directus.get_albums!()
|
||||
|> Enum.map(fn %{
|
||||
"album" => album,
|
||||
"artist" => artist,
|
||||
"externalId" => id,
|
||||
"year" => year,
|
||||
"purchased_at" => purchased_on,
|
||||
"contents" => contents,
|
||||
"songs" => songs
|
||||
} ->
|
||||
{img, ""} = Integer.parse(id)
|
||||
|
||||
%{
|
||||
id: id,
|
||||
img: to_string(img - 1) <> ".jpg",
|
||||
title: album,
|
||||
artist: artist,
|
||||
album: album,
|
||||
year: year,
|
||||
purchased_on: purchased_on,
|
||||
description: Earmark.as_html!(contents),
|
||||
songs: Enum.map(songs, fn %{"title" => song} -> song end)
|
||||
}
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
def events(conn, _) do
|
||||
json(
|
||||
conn,
|
||||
Directus.get_events!(limit: 9999)
|
||||
|> Enum.map(fn %{
|
||||
"title" => title,
|
||||
"lead" => lead,
|
||||
"poster" => img,
|
||||
"started_at" => date,
|
||||
"location" => %{
|
||||
"name" => venue,
|
||||
"address" => region,
|
||||
"position" => %{
|
||||
"coordinates" => [lat, lng]
|
||||
}
|
||||
},
|
||||
"bands" => bands
|
||||
} ->
|
||||
%{
|
||||
title: title,
|
||||
lead: lead,
|
||||
img: img,
|
||||
date: String.slice(date, 0..9),
|
||||
region: region,
|
||||
venue: venue,
|
||||
location: [lng, lat],
|
||||
bands: Enum.map(bands, fn %{"artists_id" => %{"name" => band}} -> band end)
|
||||
}
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
def interactive_event_map(conn, _) do
|
||||
text(
|
||||
conn |> put_resp_content_type("text/javascript"),
|
||||
Directus.get_events!(limit: 9999)
|
||||
|> Enum.map(fn %{
|
||||
"title" => title,
|
||||
"started_at" => date,
|
||||
"location" => %{
|
||||
"name" => venue,
|
||||
"address" => region,
|
||||
"position" => %{
|
||||
"coordinates" => [lat, lng]
|
||||
}
|
||||
}
|
||||
} ->
|
||||
%{
|
||||
title: title,
|
||||
date: String.slice(date, 0..9),
|
||||
region: region,
|
||||
venue: venue,
|
||||
longitude: lng,
|
||||
latitude: lat
|
||||
}
|
||||
end)
|
||||
|> Mse25Web.FeedView.event_map()
|
||||
)
|
||||
end
|
||||
end
|
||||
269
lib/mse25_web/controllers/feed_view.ex
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
defmodule Mse25Web.FeedView do
|
||||
use Mse25Web, :html
|
||||
|
||||
def rss(items, _host) do
|
||||
~s"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:georss="http://www.georss.org/georss" xmlns:gml="http://www.opengis.net/gml">
|
||||
<channel>
|
||||
<title>madr.se</title>
|
||||
<description>The online home of Anders Englöf Ytterström, a metalhead and musician living and working in Borlänge, Sweden.</description>
|
||||
<language>sv</language>
|
||||
<link>https://madr.se/</link>
|
||||
<managingEditor>yttan@fastmail.se (Anders Englöf Ytterström)</managingEditor>
|
||||
<webMaster>yttan@fastmail.se (Anders Englöf Ytterström)</webMaster>
|
||||
<atom:link href="https://madr.se/prenumerera.xml" rel="self" type="application/rss+xml" />
|
||||
#{Enum.map(items, &rss_item/1)}
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
end
|
||||
|
||||
def calendar(upcoming) do
|
||||
~s"""
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//https://madr.se//kommande-evenemang.ics//SE
|
||||
CALSCALE:GREGORIAN
|
||||
X-ORIGINAL-URL:https://madr.se
|
||||
X-WR-CALDESC: Kommande evenemang, madr.se
|
||||
METHOD:PUBLISH
|
||||
REFRESH-INTERVAL;VALUE=DURATION:PT1H
|
||||
X-Robots-Tag:noindex
|
||||
X-PUBLISHED-TTL:PT1H
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:CEST
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:20000630T000000
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
#{upcoming |> Enum.map(fn %{id: id, title: title, created_at: created_at, starts_at: starts_at, ends_at: ends_at, longitude: longitude, latitude: latitude, lead: lead, venue: venue, region: region} -> ~s"""
|
||||
BEGIN:VEVENT
|
||||
UID:#{starts_at}.#{id}@madr.se
|
||||
DTSTAMP:#{created_at}
|
||||
CREATED:#{created_at}
|
||||
LAST-MODIFIED:#{created_at}
|
||||
DTSTART;TZID=CEST:#{starts_at}T060606
|
||||
DTEND;TZID=CEST:#{ends_at}T060606
|
||||
SUMMARY:#{title}
|
||||
DESCRIPTION:#{lead}
|
||||
LOCATION:#{venue}\, #{region}
|
||||
GEO:#{latitude};#{longitude}
|
||||
END:VEVENT
|
||||
""" end) |> Enum.join("")}END:VCALENDAR
|
||||
"""
|
||||
end
|
||||
|
||||
def event_map(markers) do
|
||||
~s"""
|
||||
(function(g, document) {
|
||||
"use strict";
|
||||
|
||||
const mapData = [
|
||||
#{markers |> Enum.map(fn %{date: date, latitude: latitude, longitude: longitude, title: title, region: region, venue: venue} -> ~s"""
|
||||
{
|
||||
location: [#{longitude}, #{latitude}],
|
||||
title: "#{title}",
|
||||
date: "#{date}",
|
||||
region: "#{region}",
|
||||
venue: "#{venue}"
|
||||
}
|
||||
""" end) |> Enum.join(",")}
|
||||
]
|
||||
|
||||
// insert Leaflet styles (<link>) to <head> and <script> to
|
||||
// bottom of <body>, and initiate the map when the assets have
|
||||
// loaded.
|
||||
var install = function() {
|
||||
var styles, leaflet;
|
||||
|
||||
styles = document.createElement("link");
|
||||
styles.rel = "stylesheet";
|
||||
styles.href = "https://unpkg.com/leaflet@1.6.0/dist/leaflet.css";
|
||||
styles.integrity =
|
||||
"sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==";
|
||||
styles.crossOrigin = "";
|
||||
|
||||
document.head.appendChild(styles);
|
||||
|
||||
leaflet = document.createElement("script");
|
||||
leaflet.src = "https://unpkg.com/leaflet@1.6.0/dist/leaflet.js";
|
||||
leaflet.integrity =
|
||||
"sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew==";
|
||||
leaflet.crossOrigin = "";
|
||||
leaflet.async = true;
|
||||
leaflet.onload = function() {
|
||||
init(mapData);
|
||||
};
|
||||
|
||||
document.body.appendChild(leaflet);
|
||||
};
|
||||
|
||||
// inititate the map.
|
||||
// create markers for all events: one marker per venue. Display all
|
||||
// events on each venue through a popup by clicking on the marker.
|
||||
// set bounds to have all markers visible.
|
||||
var init = function(mapData) {
|
||||
var events,
|
||||
markers = {},
|
||||
L = g.L;
|
||||
|
||||
if (L) {
|
||||
// create map and set initial bounds
|
||||
events = L.map("leaflet", { zoomDelta: 0.25, zoomSnap: 0 }).fitBounds(
|
||||
mapData.map(function(e) {
|
||||
return e.location;
|
||||
})
|
||||
);
|
||||
|
||||
// use OpenStreetMap tile layer: it's free!
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?{foo}", {
|
||||
foo: "bar",
|
||||
attribution:
|
||||
'Map data © <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
|
||||
}).addTo(events);
|
||||
|
||||
// group events by venue
|
||||
mapData.forEach(function(e) {
|
||||
if (markers[e.venue]) {
|
||||
markers[e.venue].events.push(e.date + " - " + e.title);
|
||||
} else {
|
||||
markers[e.venue] = {
|
||||
region: e.region,
|
||||
location: e.location,
|
||||
events: [e.date + " - " + e.title]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// create markers
|
||||
for (var m in markers) {
|
||||
if (markers.hasOwnProperty(m)) {
|
||||
L.marker(markers[m].location)
|
||||
.addTo(events)
|
||||
.bindPopup(
|
||||
"<strong style='color: black'>" +
|
||||
m +
|
||||
", " +
|
||||
markers[m].region +
|
||||
"</strong><br>" +
|
||||
markers[m].events.join("<br>")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
install();
|
||||
})(this, document);
|
||||
"""
|
||||
end
|
||||
|
||||
def pub_date(nil), do: ""
|
||||
def pub_date(%{"pubDate" => date}), do: format_rfc822(date)
|
||||
def pub_date(%{"started_at" => date}), do: format_rfc822(date)
|
||||
def pub_date(%{"purchased_at" => date}), do: format_rfc822(date)
|
||||
|
||||
defp format_rfc822(date_time) when is_binary(date_time) do
|
||||
date_time
|
||||
|> Date.from_iso8601!()
|
||||
|> format_rfc822()
|
||||
end
|
||||
|
||||
defp format_rfc822(date_time), do: Calendar.strftime(date_time, "%a, %d %b %Y 06:06:06 +0200")
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :articles,
|
||||
"title" => title,
|
||||
"contents" => contents,
|
||||
"slug" => slug,
|
||||
"pubDate" => date
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{title}</title>
|
||||
<link>https://madr.se/#{slug}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{slug}</guid>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :events,
|
||||
"title" => title,
|
||||
"contents" => contents,
|
||||
"slug" => slug,
|
||||
"location" => %{
|
||||
"position" => %{
|
||||
"coordinates" => [lng, lat]
|
||||
}
|
||||
},
|
||||
"started_at" => date
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{title}</title>
|
||||
<link>https://madr.se/#{slug}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{slug}</guid>
|
||||
<georss:where>
|
||||
<gml:Point>
|
||||
<gml:pos>#{to_string(lng) <> " " <> to_string(lat)}</gml:pos>
|
||||
</gml:Point>
|
||||
</georss:where>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :links,
|
||||
"title" => title,
|
||||
"contents" => contents,
|
||||
"slug" => slug,
|
||||
"pubDate" => date,
|
||||
"source" => link
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{title}</title>
|
||||
<link>#{link}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{slug}</guid>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :albums,
|
||||
"summary" => summary,
|
||||
"contents" => contents,
|
||||
"purchased_at" => date,
|
||||
"externalId" => id,
|
||||
"purchase_year" => year
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{summary}</title>
|
||||
<link>https://madr.se/#{year}/#{id}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{year}/#{id}</guid>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -34,13 +34,24 @@ defmodule Mse25Web.ItemController do
|
|||
end
|
||||
end
|
||||
|
||||
defp fetch([_year, album_id], :album) do
|
||||
case Directus.get_album(album_id) do
|
||||
{:ok, response} -> {:ok, :album, response}
|
||||
not_found -> not_found
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch([year, "brutal-legend-" <> external_id]) do
|
||||
fetch([year, external_id], :album)
|
||||
end
|
||||
|
||||
defp fetch([year, slug]) do
|
||||
fetch([year, slug], :article)
|
||||
end
|
||||
|
||||
defp fetch([slug]) do
|
||||
case Integer.parse(slug) do
|
||||
{:error} ->
|
||||
:error ->
|
||||
case Directus.get_page(slug) do
|
||||
{:ok, response} -> {:ok, :page, response}
|
||||
error -> error
|
||||
|
|
@ -62,6 +73,7 @@ defmodule Mse25Web.ItemController do
|
|||
do: [
|
||||
year: year,
|
||||
page_title: "Innehåll från " <> to_string(year),
|
||||
breadcrumbs: [{year, year}],
|
||||
timeline: timeline,
|
||||
brutal_legends_count: Map.get(counts, :albums, 0),
|
||||
article_count: Map.get(counts, :articles, 0),
|
||||
|
|
@ -75,8 +87,11 @@ defmodule Mse25Web.ItemController do
|
|||
"pubDate" => published_at,
|
||||
"date_updated" => updated_at
|
||||
}) do
|
||||
year = String.slice(published_at, 0..3)
|
||||
|
||||
[
|
||||
page_title: heading,
|
||||
breadcrumbs: [{"webblogg", "Webblogg"}, {year, year, ""}],
|
||||
heading: heading,
|
||||
contents: Earmark.as_html!(contents),
|
||||
published_at: published_at,
|
||||
|
|
@ -85,7 +100,7 @@ defmodule Mse25Web.ItemController do
|
|||
nil -> published_at
|
||||
ua -> String.slice(ua, 0..9)
|
||||
end,
|
||||
year: String.slice(published_at, 0..3)
|
||||
year: year
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -93,22 +108,28 @@ defmodule Mse25Web.ItemController do
|
|||
"title" => heading,
|
||||
"contents" => contents,
|
||||
"started_at" => started_at,
|
||||
"ended_at" => ended_at,
|
||||
"lead" => lead,
|
||||
"poster" => poster,
|
||||
"bands" => bands,
|
||||
"mia" => mia,
|
||||
"category" => category
|
||||
}) do
|
||||
year = String.slice(started_at, 0..3)
|
||||
|
||||
[
|
||||
page_title: heading,
|
||||
breadcrumbs: [{"evenemang", "Evenemang"}, {year, year, ""}],
|
||||
heading: heading,
|
||||
contents: Earmark.as_html!(contents),
|
||||
lead: lead,
|
||||
year: String.slice(started_at, 0..3),
|
||||
year: year,
|
||||
poster: poster,
|
||||
bands: bands,
|
||||
mia: mia,
|
||||
category: category
|
||||
category: category,
|
||||
started_at: started_at,
|
||||
ended_at: ended_at
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -120,14 +141,17 @@ defmodule Mse25Web.ItemController do
|
|||
"source" => url,
|
||||
"h1" => title
|
||||
}) do
|
||||
year = String.slice(published_at, 0..3)
|
||||
|
||||
[
|
||||
page_title: heading,
|
||||
breadcrumbs: [{"delningar", "Delningar"}, {year, year, ""}],
|
||||
heading: heading,
|
||||
contents: Earmark.as_html!(contents),
|
||||
published_at: published_at,
|
||||
url: url,
|
||||
title: title,
|
||||
year: String.slice(published_at, 0..3),
|
||||
year: year,
|
||||
updated_at:
|
||||
case updated_at do
|
||||
nil -> published_at
|
||||
|
|
@ -142,9 +166,39 @@ defmodule Mse25Web.ItemController do
|
|||
"date_updated" => updated_at
|
||||
}) do
|
||||
[
|
||||
page_title: heading,
|
||||
breadcrumbs: [],
|
||||
heading: heading,
|
||||
contents: Earmark.as_html!(contents),
|
||||
updated_at: String.slice(updated_at, 0..9)
|
||||
]
|
||||
end
|
||||
|
||||
defp assigns(:album, %{
|
||||
"year" => year,
|
||||
"album" => album,
|
||||
"contents" => contents,
|
||||
"cover" => cover,
|
||||
"purchased_at" => purchased_at,
|
||||
"externalId" => count,
|
||||
"songs" => songs,
|
||||
"summary" => summary,
|
||||
"artist" => artist
|
||||
}) do
|
||||
purchase_year = String.slice(purchased_at, 0..3)
|
||||
|
||||
[
|
||||
page_title: summary,
|
||||
breadcrumbs: [{purchase_year, purchase_year}],
|
||||
count: count,
|
||||
album: album,
|
||||
cover: cover,
|
||||
year: to_string(year),
|
||||
purchase_year: purchase_year,
|
||||
contents: Earmark.as_html!(contents),
|
||||
songs: Enum.map(songs, fn %{"title" => name} -> "\"" <> name <> "\"" end),
|
||||
artist: artist,
|
||||
summary: summary
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
31
lib/mse25_web/controllers/item_html/album.html.heex
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<article class="album" vocab="https://schema.org/" typeof="MusicAlbum Review">
|
||||
<h1>
|
||||
<%= @summary %>
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
Som jag tidigare skrivit om har jag ett roligt projekt pågående: att äga alla låtar i spelet Brütal Legend äldre än 1990 på vinyl, där det är möjligt.
|
||||
</p>
|
||||
|
||||
<div class="brutal-legend">
|
||||
<p>
|
||||
#<%= @count %>: <span property="byArtist"><%= @artist %></span>
|
||||
- <%= csl(@songs) %>, från <span property="name"><%= @album %></span>
|
||||
(<span property="copyrightYear"><%= @year %></span>)
|
||||
</p>
|
||||
<%= if @cover do %>
|
||||
<img
|
||||
property="image"
|
||||
src={"https://n.madr.se/assets/" <> @cover <> "?key=cover"}
|
||||
alt="Skivomslag"
|
||||
loading="lazy"
|
||||
height="333"
|
||||
width="333"
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div property="reviewBody">
|
||||
<%= raw(@contents) %>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -1,12 +1,3 @@
|
|||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1><%= @page_title %></h1>
|
||||
<ul>
|
||||
<li><a href={"/" <> to_string(@year - 1)}>Tillbaka till <%= @year - 1 %></a></li>
|
||||
|
|
@ -38,58 +29,71 @@
|
|||
<section id={"m" <> month}>
|
||||
<h2><%= month_name(month) <> ", " <> to_string(@year) %></h2>
|
||||
<%= for item = %{t: t} <- items do %>
|
||||
<article>
|
||||
<%= if t == :articles do %>
|
||||
<h3>
|
||||
<article class="article" vocab="https://schema.org/" typeof="Article">
|
||||
<h3 property="name">
|
||||
<a href={"/" <> item["slug"]}>
|
||||
<%= item["title"] %>
|
||||
</a>
|
||||
</h3>
|
||||
<time property="datePublished"><%= item["pubDate"] %></time>
|
||||
</article>
|
||||
<% end %>
|
||||
<%= if t == :events do %>
|
||||
<article class="event" vocab="https://schema.org/" typeof="Event">
|
||||
<h3>
|
||||
<a href={"/" <> item["slug"]}>
|
||||
<a property="name" href={"/" <> item["slug"]}>
|
||||
<%= item["title"] %>
|
||||
</a>
|
||||
</h3>
|
||||
<p><%= item["lead"] %></p>
|
||||
<p property="description"><%= item["lead"] %></p>
|
||||
<%= if item["poster"] do %>
|
||||
<img
|
||||
property="thumbnail"
|
||||
src={ "https://n.madr.se/assets/" <> item["poster"] <> "?key=poster"}
|
||||
loading="lazy"
|
||||
alt="Affisch"
|
||||
width="200"
|
||||
/>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
<%= if t == :links do %>
|
||||
<article vocab="https://schema.org/" typeof="WebContent Review" class="bookmark">
|
||||
<h3>
|
||||
<%= item["title"] %>
|
||||
<span property="name"><%= item["title"] %></span>
|
||||
<a class="permalink" href={"/" <> item["slug"]} title="Permalänk">#</a>
|
||||
</h3>
|
||||
<p><%= raw(Earmark.as_html!(item["contents"])) %></p>
|
||||
Källa:
|
||||
<a href={"/" <> item["source"]}>
|
||||
<%= item["h1"] %>
|
||||
</a>
|
||||
<div property="reviewBody">
|
||||
<%= item["contents"] |> Earmark.as_html!() |> raw %>
|
||||
</div>
|
||||
<div class="source">
|
||||
Källa: <a href={item["source"]} rel="external"><%= item["h1"] %></a>
|
||||
</div>
|
||||
</article>
|
||||
<% end %>
|
||||
<%= if t == :albums do %>
|
||||
<article class="album" vocab="https://schema.org/" typeof="MusicAlbum">
|
||||
<h3>
|
||||
<%= item["artist"] <>
|
||||
" - " <> item["album"] <> " (" <> to_string(item["year"]) <> ")" %>
|
||||
<span property="byArtist"><%= item["artist"] %></span>
|
||||
- <%= Enum.map(item["songs"], fn %{"title" => name} -> name end) |> csl() %>,
|
||||
från <span property="name"><%= item["album"] %></span>
|
||||
(<span property="copyrightYear"><%= item["year"] %></span>)
|
||||
<a
|
||||
class="permalink"
|
||||
href={"/" <> to_string(@year) <> "/brutal-legend-" <> item["externalId"]}
|
||||
href={"/" <> to_string(item["year"]) <> "/brutal-legend-" <> item["externalId"]}
|
||||
>
|
||||
#
|
||||
</a>
|
||||
</h3>
|
||||
<%= if item["contents"] do %>
|
||||
<p><%= raw(Earmark.as_html!(item["contents"])) %></p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<%= for song <- item["songs"] do %>
|
||||
<li><%= song["artist"]["name"] <> " - " <> song["title"] %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<%= if item["cover"] do %>
|
||||
<img
|
||||
property="thumbnail"
|
||||
src={"https://n.madr.se/assets/" <> item["cover"] <> "?key=rectangular"}
|
||||
alt="Skivomslag"
|
||||
loading="lazy"
|
||||
|
|
@ -99,6 +103,6 @@
|
|||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,15 @@
|
|||
<article class="article">
|
||||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/webblogg">
|
||||
<span itemprop="name">Webblogg</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="2" />
|
||||
</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href={"/" <> @year}>
|
||||
<span itemprop="name"><%= @year %></span>
|
||||
</a>
|
||||
<meta itemprop="position" content="3" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1><%= @heading %></h1>
|
||||
</header>
|
||||
<article class="article" vocab="https://schema.org/" typeof="Article">
|
||||
<h1 property="name"><%= @heading %></h1>
|
||||
|
||||
<div property="articleBody">
|
||||
<%= raw(@contents) %>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Publicerad <%= @published_at %> <br />och senast uppdaterad <%= @updated_at %>
|
||||
Publicerad <time property="datePublished"><%= @published_at %></time>
|
||||
av <span property="publisher">Anders Englöf Ytterström</span>, <br /> senast uppdaterad
|
||||
<time property="dateModified"><%= @updated_at %></time>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,38 @@
|
|||
<article class="article">
|
||||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/evenemang">
|
||||
<span itemprop="name">Evenemang</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="2" />
|
||||
</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href={"/" <> @year}>
|
||||
<span itemprop="name"><%= @year %></span>
|
||||
</a>
|
||||
<meta itemprop="position" content="3" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1><%= @heading %></h1>
|
||||
</header>
|
||||
<article class="event" vocab="https://schema.org/" typeof="Event Review">
|
||||
<h1 property="name"><%= @heading %></h1>
|
||||
|
||||
<ul>
|
||||
<li><%= @lead %></li>
|
||||
<li property="description"><%= @lead %></li>
|
||||
<li class="sr-only">
|
||||
<span property="startDate"><%= @started_at %></span>–<span property="endDate"><%= @ended_at %></span>
|
||||
</li>
|
||||
<%= if opening_acts?(%{"bands" => @bands, "category" => @category}) do %>
|
||||
<li>Huvudakt: <%= @bands |> List.first() |> Map.get("artists_id") |> Map.get("name") %></li>
|
||||
<li>Förband: <%= @bands |> Enum.drop(1) |> bandlist() %></li>
|
||||
<li>
|
||||
Huvudakt:
|
||||
<span property="performer">
|
||||
<%= @bands |> List.first() |> Map.get("artists_id") |> Map.get("name") %>
|
||||
</span>
|
||||
</li>
|
||||
<li>Förband: <%= @bands |> Enum.drop(1) |> rdfa_bandlist() |> raw %></li>
|
||||
<% end %>
|
||||
<%= if hilights?(%{"bands" => @bands, "category" => @category}) do %>
|
||||
<li>Personliga höjdpunkter: <%= @bands |> bandlist() %></li>
|
||||
<li>Personliga höjdpunkter: <%= @bands |> rdfa_bandlist() |> raw %></li>
|
||||
<% end %>
|
||||
<%= if missed?(%{"mia" => @mia, "category" => @category}) do %>
|
||||
<li>Band jag missade: <%= @mia |> bandlist() %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<div property="reviewBody">
|
||||
<%= raw(@contents) %>
|
||||
</div>
|
||||
|
||||
<%= if @poster do %>
|
||||
<img src={"https://n.madr.se/assets/" <> @poster} alt="affisch" loading="lazy" />
|
||||
<img
|
||||
property="image"
|
||||
src={"https://n.madr.se/assets/" <> @poster <> "?key=poster"}
|
||||
alt="affisch"
|
||||
loading="lazy"
|
||||
/>
|
||||
<% end %>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,16 @@
|
|||
<article class="article">
|
||||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/delningar">
|
||||
<span itemprop="name">Delningar</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="2" />
|
||||
</li>
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href={"/" <> @year}>
|
||||
<span itemprop="name"><%= @year %></span>
|
||||
</a>
|
||||
<meta itemprop="position" content="3" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1><%= @heading %></h1>
|
||||
</header>
|
||||
|
||||
<article vocab="https://schema.org/" typeof="WebContent Review" class="bookmark">
|
||||
<h1 property="name"><%= @heading %></h1>
|
||||
<div property="reviewBody">
|
||||
<%= raw(@contents) %>
|
||||
<p>
|
||||
Källa: <a href={@url} rel="external"><%= @title %></a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="source">
|
||||
Källa: <a href={@url} property="url"><span property="headline"><%= @title %></span></a>
|
||||
</div>
|
||||
<footer>
|
||||
<p>
|
||||
Publicerad <%= @published_at %> <br />och senast uppdaterad <%= @updated_at %>
|
||||
Publicerad <time property="datePublished archivedAt"><%= @published_at %></time>
|
||||
av <span property="author">Anders Englöf Ytterström</span>,<br />Senast uppdaterad
|
||||
<time property="dateModified"><%= @updated_at %></time>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
<article>
|
||||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1><%= @heading %></h1>
|
||||
</header>
|
||||
<article class="article" vocab="https://schema.org/" typeof="Article">
|
||||
<h1 property="name"><%= @heading %></h1>
|
||||
|
||||
<div property="articleBody">
|
||||
<%= raw(@contents) %>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Senast uppdaterad <%= @updated_at %></p>
|
||||
<p>
|
||||
Senast uppdaterad <time property="dateModified"><%= @updated_at %></time>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<article>
|
||||
<header>
|
||||
<date><%= @published_at %></date>
|
||||
<h1><%= @heading %></h1>
|
||||
</header>
|
||||
|
||||
<%= raw @contents %>
|
||||
|
||||
<footer>
|
||||
<p>Skribent: Anders Englöf Ytterström. Publicerad <%= @published_at %> och senast uppdaterad <%= @updated_at %>.</p>
|
||||
</footer>
|
||||
</article>
|
||||
|
|
@ -2,11 +2,14 @@ defmodule Mse25Web.PageController do
|
|||
use Mse25Web, :controller
|
||||
|
||||
alias Mse25.Directus
|
||||
alias Mse25.Timeline
|
||||
|
||||
@almost_infinity 9999
|
||||
|
||||
def home(conn, _params) do
|
||||
[most_recent_article, older_article] = Directus.get_articles!(limit: 2)
|
||||
recent_event = Directus.get_events!(limit: 1)
|
||||
upcoming_events = Directus.get_events!(limit: 1, upcoming: true)
|
||||
upcoming_events = Directus.get_events!(limit: 2, upcoming: true)
|
||||
brutal_legends = Directus.get_albums!(limit: 1)
|
||||
|
||||
render(conn, :home,
|
||||
|
|
@ -20,17 +23,46 @@ defmodule Mse25Web.PageController do
|
|||
)
|
||||
end
|
||||
|
||||
def articles(conn, params) do
|
||||
articles =
|
||||
case params do
|
||||
%{"q" => query_string} -> Directus.get_articles!(limit: 9999, query: query_string)
|
||||
_ -> Directus.get_articles!(limit: 9999)
|
||||
def search(conn, %{"q" => ""}) do
|
||||
redirect(conn, to: ~p"/")
|
||||
end
|
||||
|
||||
def search(conn, %{"q" => query}) do
|
||||
{:ok, %{results: results, count: count}} = Timeline.search(query)
|
||||
|
||||
scount =
|
||||
case count do
|
||||
0 -> "Inga"
|
||||
c -> to_string(c)
|
||||
end
|
||||
|
||||
render(conn, :search,
|
||||
q: query,
|
||||
breadcrumbs: [],
|
||||
page_title: scount <> " sökresultat för \"" <> query <> "\"",
|
||||
results: results
|
||||
)
|
||||
end
|
||||
|
||||
def search(conn, _params) do
|
||||
redirect(conn, to: ~p"/")
|
||||
end
|
||||
|
||||
def articles(conn, params) do
|
||||
{articles, page_title} =
|
||||
case params do
|
||||
%{"q" => query_string} ->
|
||||
{Directus.get_articles!(limit: @almost_infinity, query: query_string),
|
||||
"Webblogg: \"#{query_string}\""}
|
||||
|
||||
_ ->
|
||||
{Directus.get_articles!(limit: @almost_infinity), "Webblogg"}
|
||||
end
|
||||
|> group_annually
|
||||
|
||||
render(conn, :articles,
|
||||
page_title: "Webblogg",
|
||||
articles: articles,
|
||||
page_title: page_title,
|
||||
breadcrumbs: [],
|
||||
articles: group_annually(articles),
|
||||
q: params["q"],
|
||||
nosearch?: params["q"] == nil or params["q"] == ""
|
||||
)
|
||||
|
|
@ -41,13 +73,18 @@ defmodule Mse25Web.PageController do
|
|||
|
||||
events =
|
||||
case params do
|
||||
%{"q" => query_string} -> Directus.get_events!(limit: 9999, query: query_string)
|
||||
_ -> Directus.get_events!(limit: 9999)
|
||||
%{"q" => query_string} ->
|
||||
Directus.get_events!(limit: @almost_infinity, query: query_string)
|
||||
|
||||
_ ->
|
||||
Directus.get_events!(limit: @almost_infinity)
|
||||
end
|
||||
|> group_annually
|
||||
|
||||
render(conn, :events,
|
||||
page_title: title,
|
||||
breadcrumbs: [],
|
||||
show_interactive_event_map?: true,
|
||||
contents: Earmark.as_html!(contents),
|
||||
events: events,
|
||||
q: params["q"],
|
||||
|
|
@ -56,10 +93,11 @@ defmodule Mse25Web.PageController do
|
|||
end
|
||||
|
||||
def links(conn, _params) do
|
||||
links = Directus.get_links!(limit: 9999) |> group_by_date
|
||||
links = Directus.get_links!(limit: @almost_infinity) |> group_by_date
|
||||
|
||||
render(conn, :links,
|
||||
page_title: "Delningar",
|
||||
breadcrumbs: [],
|
||||
links: links
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
defmodule Mse25Web.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use Mse25Web, :html
|
||||
import Mse25.EventHelpers
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1>Webblogg</h1>
|
||||
<h1>
|
||||
<%= @page_title %>
|
||||
</h1>
|
||||
<p>
|
||||
Inlägg skrivna sedan 2006.
|
||||
<%= if @nosearch? do %>
|
||||
|
|
@ -36,14 +29,13 @@
|
|||
<h2 class="sticky"><%= year %></h2>
|
||||
<div class="articles">
|
||||
<%= for article <- articles do %>
|
||||
<article>
|
||||
<h2>
|
||||
<article class="article" vocab="https://schema.org/" typeof="Article">
|
||||
<h2 property="name">
|
||||
<a href={"/" <> article["slug"]}><%= article["title"] %></a>
|
||||
</h2>
|
||||
<date><%= article["pubDate"] %></date>
|
||||
<time><%= article["pubDate"] %></time>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1><%= @page_title %></h1>
|
||||
|
||||
<%= raw(@contents) %>
|
||||
<section id="map">
|
||||
<h2>Geografisk utspridning</h2>
|
||||
<figure>
|
||||
<div id="leaflet" class="interactive-map"></div>
|
||||
</figure>
|
||||
</section>
|
||||
<p>
|
||||
<%= if @nosearch? do %>
|
||||
Gå direkt till:
|
||||
|
|
@ -36,14 +34,14 @@
|
|||
<h2 class="sticky"><%= year %></h2>
|
||||
<div class="events">
|
||||
<%= for event <- events do %>
|
||||
<article>
|
||||
<article class="event" vocab="https://schema.org/" typeof="Event">
|
||||
<h2>
|
||||
<a href={"/" <> event["slug"]}><%= event["title"] %></a>
|
||||
<a property="name" href={"/" <> event["slug"]}><%= event["title"] %></a>
|
||||
</h2>
|
||||
<p><%= event["lead"] %></p>
|
||||
<p property="description"><%= event["lead"] %></p>
|
||||
<%= if hilights?(event) do %>
|
||||
<p>
|
||||
Personliga höjdpunkter: <%= bandlist(event["bands"]) %>
|
||||
Personliga höjdpunkter: <%= rdfa_bandlist(event["bands"]) |> raw %>
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if missed?(event) do %>
|
||||
|
|
@ -53,13 +51,14 @@
|
|||
<% end %>
|
||||
<%= if opening_acts?(event) do %>
|
||||
<p>
|
||||
Förband: <%= event["bands"] |> Enum.drop(1) |> bandlist() %>
|
||||
Förband: <%= event["bands"] |> Enum.drop(1) |> rdfa_bandlist() |> raw %>
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if event["poster"] do %>
|
||||
<img
|
||||
property="thumbnail"
|
||||
loading="lazy"
|
||||
src={"https://n.madr.se/assets/" <> event["poster"]}
|
||||
src={"https://n.madr.se/assets/" <> event["poster"] <> "?key=poster"}
|
||||
alt="affisch"
|
||||
width="150"
|
||||
/>
|
||||
|
|
@ -69,4 +68,3 @@
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,67 +1,134 @@
|
|||
<main class="landing">
|
||||
<img src={~p"/images/aey.svg"} width="300" alt="Anders Englöf Ytterström" />
|
||||
<form metod="get" action="/search">
|
||||
<label for="q">Sök innehåll</label>: <input size="9" type="search" id="q" name="q" />
|
||||
<button>Sök</button>
|
||||
</form>
|
||||
<div class="tree">
|
||||
<div>
|
||||
Senast skrivet (<date><%= @recent_article["pubDate"] %></date>):<br />
|
||||
<img src={~p"/images/aey.svg"} width="120" alt="Anders Englöf Ytterström" />
|
||||
<h1 class="home-h1">Anders Englöf Ytterström</h1>
|
||||
<ul class="tree">
|
||||
<li class="article">
|
||||
<span></span>
|
||||
<a href={"/" <> @recent_article["slug"]}>
|
||||
<%= @recent_article["title"] %>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
Dessförinnan (<date><%= @older_article["pubDate"] %></date>):<br />
|
||||
<small><time><%= @recent_article["pubDate"] %></time></small>
|
||||
</li>
|
||||
<li class="article">
|
||||
<span></span>
|
||||
<a href={"/" <> @older_article["slug"]}>
|
||||
<%= @older_article["title"] %>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/webblogg">Webbloggen</a>
|
||||
</div>
|
||||
<%= for event <- @upcoming do %>
|
||||
<div>
|
||||
Kommande: <a href={event["slug"]}><%= event["title"] %><br /><%= event["lead"] %></a>
|
||||
</div>
|
||||
<% end %>
|
||||
<small><time><%= @older_article["pubDate"] %></time></small>
|
||||
</li>
|
||||
<li class="page">
|
||||
<span></span>
|
||||
<a href="/webblogg" class="list-link">Alla Webbloggens inlägg</a>
|
||||
</li>
|
||||
<%= for event <- @recent_event do %>
|
||||
<div>
|
||||
Upplevt: <a href={event["slug"]}><%= event["title"] %><br /><%= event["lead"] %></a>
|
||||
</div>
|
||||
<li class="events">
|
||||
<%= if event["poster"] do %>
|
||||
<img
|
||||
src={"https://n.madr.se/assets/" <> event["poster"] <> "?key=thumbnail"}
|
||||
alt="affisch"
|
||||
loading="lazy"
|
||||
/>
|
||||
<% else %>
|
||||
<span></span>
|
||||
<% end %>
|
||||
<div>
|
||||
<a href="/evenemang">Evenemangstidslinje</a>
|
||||
</div>
|
||||
<div>
|
||||
Värt att uppmärksamma:
|
||||
<a href="/delningar">
|
||||
<a href={event["slug"]} title={event["lead"]}>
|
||||
<%= event["title"] %>
|
||||
</a>
|
||||
<small><time><%= event["started_at"] %></time></small>
|
||||
</li>
|
||||
<% end %>
|
||||
<%= for event <- @upcoming do %>
|
||||
<li class="events">
|
||||
<%= if event["poster"] do %>
|
||||
<img
|
||||
src={"https://n.madr.se/assets/" <> event["poster"] <> "?key=thumbnail"}
|
||||
alt="affisch"
|
||||
loading="lazy"
|
||||
/>
|
||||
<% else %>
|
||||
<span></span>
|
||||
<% end %>
|
||||
<%= event["title"] %>
|
||||
<small><time><%= event["started_at"] %></time></small>
|
||||
</li>
|
||||
<% end %>
|
||||
<li class="feed events page">
|
||||
<span></span>
|
||||
<a href="/evenemang" class="list-link">Evenemangstidslinje</a>
|
||||
<span></span>
|
||||
</li>
|
||||
<li class="feed events ics">
|
||||
<span></span>
|
||||
<a href="/kommande-evenemang.ics" class="feed-link">Kommande evenemang</a>
|
||||
<small>.ics</small>
|
||||
</li>
|
||||
<li class="feed links">
|
||||
<span></span>
|
||||
<a href="/delningar" class="list-link">
|
||||
Delningar
|
||||
</a>
|
||||
</div>
|
||||
<span></span>
|
||||
</li>
|
||||
<%= for legend <- @brutal_legends do %>
|
||||
<div>
|
||||
Införskaffat (<%= legend["purchased_at"] %>):<br />
|
||||
<a href={legend["externalId"]}>
|
||||
<li class="album">
|
||||
<%= if legend["cover"] do %>
|
||||
<img
|
||||
src={"https://n.madr.se/assets/" <> legend["cover"] <> "?key=thumbnail"}
|
||||
alt="skivomslag"
|
||||
loading="lazy"
|
||||
/>
|
||||
<% else %>
|
||||
<span></span>
|
||||
<% end %>
|
||||
<a href={"/" <> legend["purchase_year"] <> "/brutal-legend-" <> legend["externalId"]}>
|
||||
<%= legend["artist"] %> - <%= legend["album"] %> (<%= legend["year"] %>)
|
||||
</a>
|
||||
</div>
|
||||
<span></span>
|
||||
</li>
|
||||
<% end %>
|
||||
<div>
|
||||
<li class="page">
|
||||
<span></span>
|
||||
<a href="/vad-jag-gor">
|
||||
Vad jag gör
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
Mer om:
|
||||
<span></span>
|
||||
</li>
|
||||
<li class="page">
|
||||
<span></span>
|
||||
<a href="/om">
|
||||
Anders, 39, Hårdrockare
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/colophon">
|
||||
Kontakt & Kolofon
|
||||
<span></span>
|
||||
</li>
|
||||
<li class="feed rss">
|
||||
<span></span>
|
||||
<a href="/prenumerera.xml" class="feed-link">Prenumerera</a>
|
||||
<small>.rss</small>
|
||||
</li>
|
||||
<li class="">
|
||||
<span></span>
|
||||
<a clasS="feed-link" href={~p"/aey.vcf"}>
|
||||
Kontakt
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<small>.vcf</small>
|
||||
</li>
|
||||
</ul>
|
||||
<form method="get" action="/sok" class="home-search">
|
||||
<label for="q">Sök innehåll</label>: <input size="9" type="search" id="q" name="q" />
|
||||
<button>Sök</button>
|
||||
</form>
|
||||
<ul class="profiles">
|
||||
<li><a href="/cv/anders-englof-ytterstrom.html">CV</a></li>
|
||||
<li><a href="https://github.com/madr">Github</a></li>
|
||||
<li>
|
||||
<a href="https://www.discogs.com/user/madrse/collection?limit=250&sort=artist&sort_order=asc&layout=big">
|
||||
Discogs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/colophon">
|
||||
Kolofon
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
<header>
|
||||
<ol class="breadcrumbs" itemscope itemtype="https://schema.org/BreadcrumbList">
|
||||
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<a href="/" rel="home">
|
||||
<span itemprop="name">madr.se</span>
|
||||
</a>
|
||||
<meta itemprop="position" content="1" />
|
||||
</li>
|
||||
</ol>
|
||||
<h1>Delningar</h1>
|
||||
<p>
|
||||
Länkar som är värda att uppmärksammas och lämna åsikt om.
|
||||
|
|
@ -56,12 +47,14 @@
|
|||
|> String.replace(~r/ 0/, " ") %>
|
||||
</h2>
|
||||
<%= for link <- links do %>
|
||||
<article>
|
||||
<article vocab="https://schema.org/" typeof="WebContent Review" class="bookmark">
|
||||
<h3>
|
||||
<%= link["title"] %>
|
||||
<span property="name"><%= link["title"] %></span>
|
||||
<a class="permalink" href={"/" <> link["slug"]} title="Permalänk">#</a>
|
||||
</h3>
|
||||
<div property="reviewBody">
|
||||
<%= link["contents"] |> Earmark.as_html!() |> raw %>
|
||||
</div>
|
||||
<div class="source">
|
||||
Källa: <a href={link["source"]} rel="external"><%= link["h1"] %></a>
|
||||
</div>
|
||||
|
|
@ -70,4 +63,3 @@
|
|||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</header>
|
||||
|
|
|
|||
71
lib/mse25_web/controllers/page_html/search.html.heex
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<form metod="get" action="/sok">
|
||||
<label for="q">Sök innehåll</label>: <input size="9" type="search" id="q" name="q" value={@q} />
|
||||
<button>Sök</button>
|
||||
</form>
|
||||
<h1><%= @page_title %></h1>
|
||||
<%= for item = %{t: t} <- @results do %>
|
||||
<article>
|
||||
<%= if t == :articles do %>
|
||||
<h3>
|
||||
<a href={"/" <> item["slug"]}>
|
||||
<%= item["title"] %>
|
||||
</a>
|
||||
</h3>
|
||||
<% end %>
|
||||
<%= if t == :events do %>
|
||||
<h3>
|
||||
<a href={"/" <> item["slug"]}>
|
||||
<%= item["title"] %>
|
||||
</a>
|
||||
</h3>
|
||||
<p><%= item["lead"] %></p>
|
||||
<%= if item["poster"] do %>
|
||||
<img
|
||||
src={ "https://n.madr.se/assets/" <> item["poster"] <> "?key=poster"}
|
||||
loading="lazy"
|
||||
alt="Affisch"
|
||||
width="200"
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= if t == :links do %>
|
||||
<h3>
|
||||
<%= item["title"] %>
|
||||
</h3>
|
||||
<p><%= raw(Earmark.as_html!(item["contents"])) %></p>
|
||||
Källa:
|
||||
<a href={"/" <> item["source"]}>
|
||||
<%= item["h1"] %>
|
||||
</a>
|
||||
<% end %>
|
||||
<%= if t == :albums do %>
|
||||
<h3>
|
||||
<%= item["artist"] <>
|
||||
" - " <> item["album"] <> " (" <> to_string(item["year"]) <> ")" %>
|
||||
<a
|
||||
class="permalink"
|
||||
href={"/" <> to_string(@year) <> "/brutal-legend-" <> item["externalId"]}
|
||||
>
|
||||
#
|
||||
</a>
|
||||
</h3>
|
||||
<%= if item["contents"] do %>
|
||||
<p><%= raw(Earmark.as_html!(item["contents"])) %></p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<%= for song <- item["songs"] do %>
|
||||
<li><%= song["artist"]["name"] <> " - " <> song["title"] %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<%= if item["cover"] do %>
|
||||
<img
|
||||
src={"https://n.madr.se/assets/" <> item["cover"] <> "?key=rectangular"}
|
||||
alt="Skivomslag"
|
||||
loading="lazy"
|
||||
height="75"
|
||||
width="75"
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
|
|
@ -4,28 +4,39 @@ defmodule Mse25Web.Router do
|
|||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {Mse25Web.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
end
|
||||
|
||||
pipeline :scripts do
|
||||
plug :accepts, ["js"]
|
||||
plug :put_secure_browser_headers
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
scope "/", Mse25Web do
|
||||
pipe_through :scripts
|
||||
|
||||
get "/event-map.js", FeedController, :interactive_event_map
|
||||
end
|
||||
|
||||
scope "/", Mse25Web do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
|
||||
get "/evenemang", PageController, :events
|
||||
get "/webblogg", PageController, :articles
|
||||
get "/delningar", PageController, :links
|
||||
get "/sok", PageController, :search
|
||||
|
||||
# get "/kommande-evenemang.ics", EventController, :calendar
|
||||
# get "/event-map.js", EventController, :interactive_map
|
||||
# get "/prenumerera.xml", TimelineController, :feed
|
||||
get "/prenumerera.xml", FeedController, :feed
|
||||
get "/albums.json", FeedController, :albums
|
||||
get "/events.json", FeedController, :events
|
||||
get "/kommande-evenemang.ics", FeedController, :calendar
|
||||
|
||||
get "/*path", ItemController, :index
|
||||
end
|
||||
|
|
|
|||
12
priv/static/aey.vcf
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:Englöf Ytterström;Anders;;Mr.;
|
||||
FN:Anders Englöf Ytterström
|
||||
ORG:PlaymakerAI
|
||||
TITLE:Webbutvecklare/hårdrockare
|
||||
PHOTO;MEDIATYPE=image/gif:https://madr.se/mugshot.jpg
|
||||
TEL;TYPE=home,voice;VALUE=uri:tel:+46-70-216-9645
|
||||
EMAIL:yttan@fastmail.se
|
||||
REV:20191006T195243Z
|
||||
x-qq:21588891
|
||||
END:VCARD
|
||||
BIN
priv/static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
priv/static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
priv/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
1
priv/static/bl/brutal.f07f1821.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
body{background-color:#111;color:#aaa;font-size:large;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;padding:0;margin:0 auto;max-width:80em}a:link,a:visited{color:#a83}a:visited{text-decoration:line-through}a:focus,a:hover{color:#fff}a:active{transform:translate(2px,2px)}header{border-bottom:3px solid #a83;padding:.5em;margin-bottom:.5em;align-items:center}@media (min-width:500px){header{display:flex;justify-content:space-between;padding:.5em;margin:0}}@media (min-width:1200px){header{margin:.5em 0}}h1{text-transform:uppercase;margin:0;font-size:1.5em}p:first-child{margin-top:0}p:last-child{margin-bottom:0}.field{background-color:#333;color:#fff;border-width:0;border-radius:5px;font-size:large;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;padding:.5rem 1rem;display:block;width:100%;box-sizing:border-box;margin-top:.5em}.field:hover{background-color:#444}.field:focus{background-color:#fff;color:#000}@media (min-width:500px){.field{margin-top:0}input.field{min-width:17em}}.blur{filter:blur(25px)}.visuallyhidden{position:absolute;left:-9999em}.album{display:flex;align-items:start;padding:.5em;margin:.8em 0}@media (min-width:500px){.album{flex-direction:column;margin:0}}@media (min-width:1200px){.album{flex-direction:row;align-items:center}}.album:hover{background:#333;color:#fff}.album__cover{margin:0 .5em 0 0;padding-top:7px}@media (min-width:500px){.album__cover{margin:0 0 .5em;width:100%;padding-top:0}}@media (min-width:1200px){.album__cover{margin:0 1em 0 0;width:auto;padding-top:0}}.album__cover__media{width:25vw;height:25vw;position:relative}.album__cover__media:after{position:absolute;content:"";top:0;right:0;bottom:0;left:0;background-color:#fff;z-index:10}@media (min-width:500px){.album__cover__media{width:100%;height:100%}}@media (min-width:1200px){.album__cover__media{width:10vw;height:10vw}}@media (min-width:500px){.albums{display:grid;grid-template-columns:1fr 1fr 1fr;grid-gap:.5em;text-transform:uppercase;padding-bottom:2em}}.selected-album{position:fixed;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center}.selected-album__inner{overflow:auto;border:3px solid #a83;background:#000;padding:2em;max-height:80%;display:flex;flex-direction:column;align-items:center}.selected-album__summary{text-transform:uppercase;padding:.5em;margin-bottom:.5em}.selected-album__description{color:#fff;padding:0 .5rem 2em;margin:0 auto;max-width:40em}.selected-album__cover{display:none}@media (min-width:1200px){.selected-album__cover{display:block;width:75vh;max-width:900px;height:auto}.selected-album__media{width:100%;height:auto}}
|
||||
1
priv/static/bl/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="brutal.f07f1821.css"><title>🤘 Brütal Legend 🤘</title></head><body> <div id="brutal"></div> </body><script src="src.fc45d0fd.js"></script></html>
|
||||
129
priv/static/bl/src.fc45d0fd.js
Normal file
1
priv/static/cv/anders-englof-ytterstrom.html
Normal file
BIN
priv/static/cv/cv-anders-englof-ytterstrom.pdf
Normal file
266
priv/static/cv/cv.css
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
html {
|
||||
font:
|
||||
normal small/1.5 apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
sans-serif;
|
||||
background-color: var(--bg-color, #fff);
|
||||
color: var(--bg-color, #444);
|
||||
}
|
||||
body {
|
||||
font-size: clamp(0.9em, 1.5vw, 1.4em);
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1em;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 3em;
|
||||
line-height: 1;
|
||||
}
|
||||
h2 {
|
||||
border-bottom: 0.3em solid #e3e3e3;
|
||||
padding-bottom: 0.25em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: var(--bg-color, #444);
|
||||
transition:
|
||||
background-color 0.3s ease-out,
|
||||
border-bottom-color 0.3s ease-out;
|
||||
}
|
||||
p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
a:link,
|
||||
a:visited {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #bbb;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-bottom-color: #000;
|
||||
color: #000;
|
||||
}
|
||||
a:active {
|
||||
transform: translate(3px, 3px);
|
||||
}
|
||||
[href^="tel"]::before {
|
||||
content: "📞 ";
|
||||
}
|
||||
[href^="mailto"]::before {
|
||||
content: "✉️ ";
|
||||
}
|
||||
[href^="https://github.com"]::before
|
||||
{
|
||||
content: "🐙 ";
|
||||
}
|
||||
[href^="https://madr"]::before
|
||||
{
|
||||
content: "🏠 ";
|
||||
}
|
||||
[href$="pdf"] {
|
||||
font-size: 1.1em;
|
||||
padding: 0.3em;
|
||||
}
|
||||
[href$="pdf"]::before {
|
||||
content: "📑 ";
|
||||
}
|
||||
[href$="pdf"]:hover::before,
|
||||
[href$="pdf"]:focus::before {
|
||||
background-color: rgba(0, 255, 0, 0.2);
|
||||
}
|
||||
[role="doc-subtitle"] {
|
||||
font-size: 1.25em;
|
||||
color: #666;
|
||||
}
|
||||
@media (min-width: 40em) {
|
||||
.h-aside {
|
||||
border-bottom: 0;
|
||||
font-size: 1.4em;
|
||||
margin-bottom: 0;
|
||||
margin-top: 1.95em;
|
||||
}
|
||||
}
|
||||
.contact {
|
||||
margin: 1em 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2em;
|
||||
}
|
||||
@media (min-width: 40em) {
|
||||
.contact {
|
||||
margin: 0.5em 0 0;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2em;
|
||||
}
|
||||
}
|
||||
.contact > dd {
|
||||
margin-left: 0;
|
||||
}
|
||||
.contact > dt {
|
||||
position: absolute;
|
||||
left: -9999em;
|
||||
}
|
||||
.skillset {
|
||||
display: grid;
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
.skillset > dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
.skillset > dd {
|
||||
margin-left: 0;
|
||||
}
|
||||
/*
|
||||
Left for good measure, could have been gold if
|
||||
the upcoming Container queries was a thing.
|
||||
|
||||
@media (min-width: 80em) {
|
||||
.skillset {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.skillset > dt {
|
||||
transform: translateY(0.2em);
|
||||
}
|
||||
}
|
||||
*/
|
||||
.resume {
|
||||
max-width: 70em;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"name"
|
||||
"about"
|
||||
"skills"
|
||||
"work"
|
||||
"education"
|
||||
"projects"
|
||||
"courses"
|
||||
"personal";
|
||||
gap: 0;
|
||||
}
|
||||
@media (min-width: 40em) {
|
||||
.resume {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-areas:
|
||||
"name name"
|
||||
"skills about"
|
||||
"work work"
|
||||
"education courses"
|
||||
"projects personal";
|
||||
gap: 2em;
|
||||
}
|
||||
}
|
||||
.name {
|
||||
grid-area: name;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.photo {
|
||||
grid-area: photo;
|
||||
}
|
||||
.work {
|
||||
grid-area: work;
|
||||
}
|
||||
.courses {
|
||||
grid-area: courses;
|
||||
}
|
||||
.personal {
|
||||
grid-area: personal;
|
||||
}
|
||||
.about {
|
||||
grid-area: about;
|
||||
background: #f1f1f1;
|
||||
border-radius: 0.2em;
|
||||
padding: 1em;
|
||||
color: #111;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.education {
|
||||
grid-area: education;
|
||||
}
|
||||
.projects {
|
||||
grid-area: projects;
|
||||
}
|
||||
.skills {
|
||||
grid-area: skills;
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
gap: 0.5em 0.5em;
|
||||
margin: 2em 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.event--aside {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.event__title {
|
||||
margin: 0;
|
||||
}
|
||||
.event__position {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
.event__content {
|
||||
line-height: 1.5;
|
||||
padding-top: 0.5em;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
.event__aside {
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@page {
|
||||
padding: 2cm 0;
|
||||
margin: 0;
|
||||
size: A4 portrait;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[href$="pdf"] {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
border: none !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.education,
|
||||
.projects,
|
||||
.work {
|
||||
page-break-before: always;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 152 B After Width: | Height: | Size: 15 KiB |