Compare commits

...

5 Commits

14 changed files with 334 additions and 39 deletions

View File

@ -31,10 +31,12 @@ class CommentCollection(admin.Administerable):
# NOTE: This is essentially a copy of TagCollection.parent # NOTE: This is essentially a copy of TagCollection.parent
for cls in Commentable.__class_descendants__.values(): for cls in Commentable.__class_descendants__.values():
try: if cls not in app.models_abstract:
return cls.select().where(cls.comment_collection == self).get()
except cls.DoesNotExist: try:
pass # continue to next loop iteration return cls.select().where(cls.comment_collection == self).get()
except cls.DoesNotExist:
pass # continue to next loop iteration
return None return None

47
main.py
View File

@ -535,10 +535,18 @@ class ScoredLink(admin.Administerable):
autoform_blacklist = ['id', 'external_site_count', 'last_scrape'] autoform_blacklist = ['id', 'external_site_count', 'last_scrape']
hue_range = (80,320) # hue (degrees) from 0 to .max hue_range = (80,320) # hue (degrees) from 0 to .max
url = peewee.CharField(unique=True) # TODO: Regexp constraint url = peewee.CharField(unique=True) # TODO: Regexp constraint # FIXME: overrides url function
external_site_count = peewee.IntegerField(null=True) external_site_count = peewee.IntegerField(null=True)
last_scrape = peewee.DateTimeField(null=True, default=None) last_scrape = peewee.DateTimeField(null=True, default=None)
explanation_external_site_count = markdown.SafeMarkdownString("""
How many **third-party sites** the linked content loads data from.
This is relevant to your privacy because each of those third-party sites
gets at the very least to see that you visited the linked content.
*Lower* is better, **0** is *ideal*.""")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -595,6 +603,9 @@ class ScoredLink(admin.Administerable):
@property @property
def external_site_counts(self): def external_site_counts(self):
# TODO: We should probably cache this
# (and invalidate/rebuild cache at least in .save?)
cls = self.__class__ cls = self.__class__
return [ return [
@ -620,6 +631,10 @@ class ScoredLink(admin.Administerable):
def mean(self): def mean(self):
return sum(self.external_site_counts) / float(len(self.external_site_counts)) return sum(self.external_site_counts) / float(len(self.external_site_counts))
@property
def min(self):
return min(self.external_site_counts)
@property @property
def max(self): def max(self):
return max(self.external_site_counts) return max(self.external_site_counts)
@ -707,7 +722,7 @@ def renderer_link_close(self, tokens, idx, options, env):
link.save(force_insert=True) link.save(force_insert=True)
if isinstance(link.external_site_count, int): if isinstance(link.external_site_count, int):
icon = f'<img class="scoredlink-icon" src="/theme/dynamic/scoredlink/{link.id}" title="This URL loads data from {link.external_site_count} external sites (median: {link.median}, mean: {link.mean}, set size: {link.set_size})" />' icon = f'<img class="scoredlink-icon" src="/theme/dynamic/scoredlink/{link.id}" title="This URL loads data from {link.external_site_count} external sites (median: {link.median}, mean: {link.mean}, min {link.min}, max: {link.max}, set size: {link.set_size})" />'
if icon: if icon:
return f'</a>{icon}' return f'</a>{icon}'
@ -743,15 +758,12 @@ class Article(LeadImageContent):
teaser = markdown.MarkdownTextField(null=False, verbose_name='Teaser') teaser = markdown.MarkdownTextField(null=False, verbose_name='Teaser')
text = markdown.MarkdownTextField(null=False, verbose_name='Text') text = markdown.MarkdownTextField(null=False, verbose_name='Text')
@FrontPage.include class LinkForm(tagging.TaggableForm):
@app.expose('/project/')
class Project(LeadImageContent):
title = markdown.SafeMarkdownCharField(null=False, verbose_name='Title') """
teaser = markdown.MarkdownTextField(null=False, verbose_name='Teaser') More comfortable handing for a 'link' field that's a foreign key to ScoredLink.
text = markdown.MarkdownTextField(null=False, verbose_name='Text') Used by Project and CuratedContent.
"""
class CuratedArtForm(tagging.TaggableForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -791,11 +803,24 @@ class CuratedArtForm(tagging.TaggableForm):
return super().process(submit) return super().process(submit)
@FrontPage.include
@app.expose('/project/')
class Project(LeadImageContent):
autoform_class = LinkForm
autoform_blacklist = ('id', 'created', 'comment_collection_id', 'link_id')
title = markdown.SafeMarkdownCharField(null=False, verbose_name='Title')
teaser = markdown.MarkdownTextField(null=False, verbose_name='Teaser')
text = markdown.MarkdownTextField(null=False, verbose_name='Text')
link = peewee.ForeignKeyField(ScoredLink, null=True, verbose_name='Link', help_text='URL for the original content, if any')
@FrontPage.include @FrontPage.include
@app.expose('/curated/') @app.expose('/curated/')
class CuratedArt(LeadImageContent): class CuratedArt(LeadImageContent):
autoform_class = CuratedArtForm autoform_class = LinkForm
autoform_blacklist = ('id', 'created', 'comment_collection_id', 'link_id') autoform_blacklist = ('id', 'created', 'comment_collection_id', 'link_id')
title = markdown.SafeMarkdownCharField(null=False, verbose_name='Title') title = markdown.SafeMarkdownCharField(null=False, verbose_name='Title')

View File

@ -160,7 +160,7 @@ def renderable_inline_render(self, tokens, idx, options, env):
# in MarkdownString.render. # in MarkdownString.render.
body = str(instance.render(mode)) body = str(instance.render(mode))
# this is ahack to break renderables out of <section>s # this is a hack to break renderables out of <section>s
# and avoid invalid </section></p><section>…</p> # and avoid invalid </section></p><section>…</p>
# any resulting "empty" <section><p></p></section> hidden via css # any resulting "empty" <section><p></p></section> hidden via css
return f'</p></section>{ body }<section><p>' return f'</p></section>{ body }<section><p>'

View File

@ -662,10 +662,14 @@ article .unpublished {
text-decoration: none; text-decoration: none;
background: var(--color-bg-alt); background: var(--color-bg-alt);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
color: var(--color-error); color: var(--color-error-inactive);
} }
.modal a[href="#close"]:hover {
color: var(--color-error);
}
.modal img { .modal img {
/* enable images to be shown at full scale, even if too big for viewport */ /* enable images to be shown at full scale, even if too big for viewport */
max-width: none !important; max-width: none !important;
@ -687,7 +691,8 @@ article .unpublished {
background: var(--color-highlight-inactive); background: var(--color-highlight-inactive);
color: var(--color-highlight-contrast); color: var(--color-highlight-contrast);
cursor: s-resize; cursor: s-resize;
font-size: 1.5rem; font-size: 1.2rem;
font-weight: bold;
transition: background 0.2s ease-in-out; transition: background 0.2s ease-in-out;
} }
@ -855,15 +860,21 @@ article.renderable > .content > h6 {
margin: 0 auto; margin: 0 auto;
} }
article.administerable > .content > section { article.administerable > .content section {
background: hsla(0 0 5 / 90%); background: hsla(0 0 5 / 90%);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
max-width: var(--distance-typographic-width); max-width: var(--distance-typographic-width);
margin: 0 auto; margin: 0 auto;
padding: var(--distance-main); padding: var(--distance-main);
margin-bottom: var(--distance-main);
} }
article.administerable > .content > section:has(p:empty:only-child) { article.administerable > .content section:last-child {
margin-bottom: 0;
}
article.administerable > .content section:has(p:empty:only-child) {
/* this is a hack that takes care of an edge case for another hack. /* this is a hack that takes care of an edge case for another hack.
* namely, the inline_renderable and section markdown plugins have * namely, the inline_renderable and section markdown plugins have
@ -911,6 +922,7 @@ article.gallery {}
article.gallery .gallery-preview { article.gallery .gallery-preview {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center;
} }
article.gallery img.preview { article.gallery img.preview {
@ -1183,13 +1195,133 @@ body > .menus .searchminiform .field-help-wrapper {
margin: 0 auto; margin: 0 auto;
} }
/* ScoredLink view */
.scoredlink {
max-width: var(--distance-typographic-width);
margin: 0 auto;
}
.scoredlink ul.fields {
list-style: none;
margin: 0;
padding: 0;
}
.scoredlink a.url {
/*background-image: url('/theme/svg/link.svg#active');*/
background-repeat: no-repeat;
background-size: contain;
background-position-x: right;
padding-right: 1.5em; /* leave enough space for background icon */
font-size: 150%;
margin: 0 auto;
}
.scoredlink .field.external-site-count {
font-size: 120%;
}
.scoredlink .field .value {
font-weight: bold;
}
.scoredlink .field .explanation {
font-size: 1rem;
margin-bottom: var(--distance-main);
}
/* Propaganda view */ /* Propaganda view */
.propaganda .text,
.propaganda .pieces {
margin-top: var(--distance-main);
}
.propaganda.mode-full .propagandapiece {
/*padding: var(--distance-main) 0;*/
margin-bottom: var(--distance-main);
padding-bottom: 1px; /* hack to ensure margin doesn't spill out of container */
}
.propagandapiece .preview img { .propagandapiece .preview img {
max-width: 100%; max-width: 100%;
max-height: 80vh; max-height: 80vh;
} }
.propagandapiece:nth-child(odd) {
background: var(--color-bg-alt);
}
.propagandapiece:nth-child(odd) > .content > section {
background: transparent;
margin-bottom: 0;
}
.propagandapiece:nth-child(even) {
}
.propagandapiece .files {
width: var(--distance-typographic-width);
padding: var(--distance-main);
margin: var(--distance-main) auto;
}
.propagandapiece:nth-child(odd) .files {
background: var(--color-bg-main);
}
.propagandapiece:nth-child(even) .files {
background: var(--color-bg-alt);
}
.propagandapiece .files > .fileinfo {
height: 4rem; /* remove -100% y-translated filesize from height */
}
.propagandapiece .files > .fileinfo a.download {
display: block;
padding-left: 4.5rem;
line-height: 4rem; /* smaller size, determines background size */
background-image: url('/theme/svg/download.svg');
background-repeat: no-repeat;
background-size: contain;
font-weight: bold;
}
.propagandapiece .files > .fileinfo a.download:hover {
background-image: url('/theme/svg/download.svg#hover');
}
.propagandapiece .files > .fileinfo a.download:active {
background-image: url('/theme/svg/download.svg#pressed');
}
.propagandapiece .files .fileinfo .filesize {
display: block;
margin-left: 4.5rem;
translate: 0 -100%;
}
/*.propagandapiece .files > .fileinfo {
display: table-row;
}
.propagandapiece .files > .fileinfo > * {
display: table-cell;
background: var(--color-highlight);
color: var(--color-highlight-contrast);
padding: var(--distance-minor);
}
.propagandapiece .files > .fileinfo > *:first-child {
font-weight: bold;
}
.propagandapiece .files > .fileinfo > *:last-child {
text-align: right;
}*/
.propagandapiece .modal article, .propagandapiece .modal article,
.propagandapiece .modal .content { .propagandapiece .modal .content {
min-height: 100vh; min-height: 100vh;
@ -1208,9 +1340,29 @@ body > .menus .searchminiform .field-help-wrapper {
.propaganda.mode-teaser .preview { .propaganda.mode-teaser .preview {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: wrap;
} }
.propaganda.mode-teaser .preview > * {
flex-shrink: 1;
flex-grow: 1;
flex-basis: 25%;
}
.propaganda.mode-teaser .preview > :first-child {
flex-basis: 100%;
flex-grow: 4;
flex-shrink: 0;
}
.propaganda.mode-teaser .preview > :nth-child(2),
.propaganda.mode-teaser .preview > :nth-child(3) {
flex-basis: 50%;
flex-grow: 2;
flex-shrink: 0;
}
.propaganda.mode-teaser .preview .content, .propaganda.mode-teaser .preview .content,
.propaganda.mode-teaser .preview article.upload, .propaganda.mode-teaser .preview article.upload,
.propaganda.mode-teaser .preview img { .propaganda.mode-teaser .preview img {

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100"
height="100"
viewBox="0 0 100 100"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<style>
rect {
fill: #580;
}
#hover:target rect {
fill: url(#fill-hover);
}
#pressed:target rect {
fill: #af0;
}
</style>
<g id="hover">
<g id="pressed">
<mask id="shape">
<path
fill="white"
d="m 20.04638,29.992405 h -9.963128 c -2.083252,0 -3.083252,2 -0.03436,5.048888 L 45.007595,70 C 50,74.992405 50,74.992405 55.045878,69.946527 L 90.032052,34.960353 C 93,31.992405 92,29.992405 90.019847,29.992405 H 80 v -15 c 0,-5 -5,-10 -10,-10 H 10 c 5,0 10,5 10,10"
id="path1" />
<path
fill="white"
d="m 5,95 h 90 c 5,0 5,-15 -10,-15 H 15 C 0,80 0,95 5,95 Z"
id="path3" />
</mask>
<linearGradient id="fill-hover" x1="0" x2="0" y1="-100%" y2="100%">
<stop offset="0%" stop-color="#580" />
<stop offset="30%" stop-color="#af0" />
<stop offset="60%" stop-color="#580" />
<animate attributeName="y1" dur="2000ms" from="-100%" to="100%" repeatCount="indefinite" />
<animate attributeName="y2" dur="2000ms" from="100%" to="200%" repeatCount="indefinite" />
</linearGradient>
<rect x="0" y="0" width="100" height="100" mask="url(#shape)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -17,6 +17,8 @@
{% if mode == 'full' and content.comment_enabled %} {% if mode == 'full' and content.comment_enabled %}
<div class="comments"> <div class="comments">
<h2>Comments</h2>
{{ content.comment_form().render() }} {{ content.comment_form().render() }}
{% for comment in content.comments %} {% for comment in content.comments %}

View File

@ -0,0 +1,11 @@
{% extends 'default/templates/renderable/leadimagecontent.html' %}
{% block content %}
{{ super() }}
{% if content.link %}
{{ content.link.render() }}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'default/templates/renderable/leadimagecontent.html' %}
{% block content %}
{{ super() }}
{% if content.link %}
{{ content.link.render() }}
{% endif %}
{% endblock %}

View File

@ -9,6 +9,7 @@
{{ piece.render('inline-teaser') }} {{ piece.render('inline-teaser') }}
{% endfor %} {% endfor %}
</div> </div>
{{ content.teaser.render() }} {{ content.teaser.render() }}
</a> </a>

View File

@ -2,10 +2,14 @@
{% block content %} {% block content %}
{{ content.text.render() }} <div class="text">
{{ content.text.render() }}
</div>
{% for piece in content.pieces_ordered %} <div class="pieces">
{{ piece.render('full') }} {% for piece in content.pieces_ordered %}
{% endfor %} {{ piece.render('full') }}
{% endfor %}
</div>
{% endblock %} {% endblock %}

View File

@ -23,7 +23,7 @@
{% for item in content.items_ordered[1:] %} {% for item in content.items_ordered[1:] %}
{% if item.upload %} {% if item.upload %}
<div class="fileinfo"> <div class="fileinfo">
<a href="{{ item.upload.url('raw') }}" target="_blank">{{ item.upload.filename }}</a> <a class="download" href="{{ item.upload.url('download') }}" target="_blank">{{ item.upload.filename }}</a>
<span class="filesize">{{ item.upload.filesize|filesizeformat }}</span> <span class="filesize">{{ item.upload.filesize|filesizeformat }}</span>
</div> </div>
{% endif %} {% endif %}

View File

@ -1,9 +1,35 @@
{% extends 'default/templates/renderable/administerable.html' %} {% extends 'default/templates/renderable/administerable.html' %}
{% block content %} {% block content %}
<ul> <a class="url" href="{{ content.url }}" style="background-image: url('/theme/dynamic/scoredlink/{{ content.id }}');">{{ content.url }}</a>
<li class="url">URL: {{ content.url }}</li> <ul class="fields">
<li class="external-site-count">External site count: {{ content.external_site_count }}</li> <li class="field external-site-count">
<li class="last-scrape">Last scrape: {{ content.last_scrape or 'never' }}</li>
</ul> <span class="name">External site count</span>
<span class="value" style="color: {{ content.color.css() }};">{{ content.external_site_count }}</span>
<input id="scoredlink-{{ content.id }}-external-site-count-toggle" class="toggle-input" type="checkbox" />
<label for="scoredlink-{{ content.id }}-external-site-count-toggle" class="toggle-label" title="{{ content.__class__.explanation_external_site_count }}">?</label>
<div class="explanation">
{{ content.__class__.explanation_external_site_count.render() }}
<span>The statistical spread of our dataset currently looks like this:</span>
<ul class="statistic-info">
<li class="stat"><span>Median:</span> <span class="value">{{ content.median }}</span></li>
<li class="stat"><span>Mean:</span> <span class="value">{{ content.mean }}</span></li>
<li class="stat"><span>Min:</span> <span class="value">{{ content.min }}</span></li>
<li class="stat"><span>Max:</span> <span class="value">{{ content.max }}</span></li>
<li class="stat"><span>Set size:</span> <span class="value">{{ content.set_size }}</span></li>
</ul>
</div>
</li>
<li class="field last-scrape">
<span class="name">Last scrape</span>
<span class="value">{{ content.last_scrape|prettydate or 'never' }}</span>
</li>
</dl>
{% endblock %} {% endblock %}

View File

@ -113,8 +113,14 @@ class Upload(admin.Named):
def url(self, mode='raw'): def url(self, mode='raw'):
if mode == 'raw': if mode in ('raw', 'download'):
return f'/upload/{self.subdirectory}/{self.name}/' # FIXME: make this less hacky, if possible
url = f'/upload/{self.subdirectory}/{self.name}/' # FIXME: make this less hacky, if possible
if mode == 'download':
url += '?download'
return url
return super().url(mode=mode) return super().url(mode=mode)
@ -291,6 +297,9 @@ def serve_upload(class_key, name):
instance = cls.load(name) # this failing is handled by error_catchall instance = cls.load(name) # this failing is handled by error_catchall
if flask.g.user or instance.published: if flask.g.user or instance.published:
return flask.send_from_directory(f'upload/{cls.subdirectory}', instance.filename)
download = 'download' in flask.request.args
return flask.send_from_directory(f'upload/{cls.subdirectory}', instance.filename, as_attachment=download)
flask.abort(404) flask.abort(404)

View File

@ -186,14 +186,11 @@ class Color(object):
self.alpha self.alpha
) )
def css_rgb(self): def css(self):
return f'rgb({self.red * 100}% {self.green * 100}% {self.blue * 100}%)'
def css_rgba(self):
""" """
newstyle rgb(), NOT rgba(). newstyle rgb(), NOT rgba().
""" """
return f'rgb({self.red * 100}% {self.green * 100}% {self.blue * 100}%) / {self.alpha * 100}%' return f'rgb({round(self.red * 255)} {round(self.green * 255)} {round(self.blue * 255)} / {self.alpha * 100}%)'
def clone(self): def clone(self):
return Color(red=self.red, green=self.green, blue=self.blue, alpha=self.alpha) return Color(red=self.red, green=self.green, blue=self.blue, alpha=self.alpha)