This commit is contained in:
Adrian Woźniak 2022-07-13 14:54:19 +02:00
parent 7cc47a7f30
commit e434b89e9e
No known key found for this signature in database
GPG Key ID: 0012845A89C7352B
17 changed files with 839 additions and 109 deletions

View File

@ -1,6 +1,4 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block head %} {% block head %}
<script src="//cdn.quilljs.com/1.3.6/quill.min.js"></script>
<link href="//cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
<script type="module" src=/assets/js/admin.js></script> <script type="module" src=/assets/js/admin.js></script>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,17 @@
{% extends "admin.html" %} {% extends "admin.html" %}
{% block content %} {% block content %}
<ow-admin> <ow-admin>
<ow-articles>
{% for article in news %}
<news-article
article-title="{{article.title}}"
status="{{article.status.as_str()}}"
published-at="{{article.published_at}}"
created-at="{{article.created_at}}"
>
{{article.body}}
</news-article>
{% endfor %}
</ow-articles>
</ow-admin> </ow-admin>
{% endblock %} {% endblock %}

View File

@ -6,8 +6,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="/assets/css/reset.css" rel="stylesheet"/> <link href="/assets/css/reset.css" rel="stylesheet"/>
<link href="/assets/css/app.css" rel="stylesheet"/> <link href="/assets/css/app.css" rel="stylesheet"/>
<link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/assets/images/favicon.ico"/>
<script type="module" src=/assets/js/app.js></script> <script type="module" src=/assets/js/app.js></script>
<style>
.error {
width: 1280px;
color: #ba3939;
background: #ffe0e0;
border: 1px solid #a33a3a;
margin: 8px auto auto;
padding: 8px;
}
</style>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>

View File

@ -1 +1,3 @@
import "./admin/ow-admin"; import "./admin/ow-admin";
import "./admin/ow-articles";
import "./admin/news-article";

View File

@ -1,49 +1,46 @@
import { Component, FORM_STYLE } from "../shared"; import { Component, FORM_STYLE } from "../shared";
import "../shared/rich-text-editor";
customElements.define('article-form', class extends Component { customElements.define('article-form', class extends Component {
#editor;
constructor() { constructor() {
super(` super(`
<style> <style>
@import "//cdn.quilljs.com/1.3.6/quill.snow.css";
:host { display: block; } :host { display: block; }
rich-text-editor {
margin-top: 16px;
margin-bottom: 16px;
}
${ FORM_STYLE } ${ FORM_STYLE }
</style> </style>
<article> <form action="/admin/news/create" method="post">
<div> <div>
<label>Tytuł</label> <label>Tytuł</label>
<input placeholder="Tytuł" name="title" /> <input placeholder="Tytuł" name="title" />
</div> </div>
<section id="body-view"> <section id="body-view">
<rich-text-editor></rich-text-editor>
<input type="hidden" name="body" />
</section>
<section>
<label>Status</label>
<select name="status">
<option selected value="pending">Oczekujący</option>
<option value="published">Opublikowany</option>
<option value="hidden">Ukryty</option>
</select>
</section> </section>
<input type="hidden" name="body" />
<input type="submit" value="Zapisz" /> <input type="submit" value="Zapisz" />
</article> </form>
`); `);
const bodyInput = this.shadowRoot.querySelector('[name="body"]');
this.shadowRoot.querySelector('rich-text-editor').addEventListener('change', ev => {
ev.stopPropagation();
bodyInput.value = ev.target.value;
});
} }
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
const view = this.shadowRoot.querySelector('#body-view');
view.innerHTML = `
<div id="scrolling-container">
<div id="editor"></div>
</div>
`;
const options = {
debug: 'info',
modules: {
toolbar: [
[{ header: [1, 2, false] }],
['bold', 'italic', 'underline'],
['image', 'code-block']
]
},
// scrollingContainer: view.querySelector('#scrolling-container'),
readOnly: false,
theme: 'snow'
};
this.#editor = new Quill(view.querySelector('#editor'), options);
} }
}); });

View File

@ -0,0 +1,94 @@
import { Component } from "../shared";
customElements.define('news-article', class extends Component {
static get observedAttributes() {
return ["article-title", "status", "body", "created-at", "published-at"]
}
constructor() {
super(`
<style>
:host { display: block; }
.time {
display: flex;
justify-content: space-between;
}
h1 {
display: flex;
}
h1 #status {
font-size: 14px;
}
#time {
display: flex;
justify-content: space-between;
}
.time span:first-child {
margin-right: 8px;
}
#title {
margin-right: 16px;
}
#body {
margin-bottom: 16px;
margin-top: 16px;
}
</style>
<article>
<h1>
<span id="title"></span>
<span id="status"></span>
</h1>
<section id="time">
<div class="time">
<span>Created at:</span>
<span id="created_at"></span>
</div>
<div class="time">
<span>Published at:</span>
<span id="published_at"></span>
</div>
</section>
<section id="body">
<slot></slot>
</section>
</article>
`);
}
get article_title() {
return this.getAttribute('article-title');
}
set article_title(v) {
this.setAttribute('article-title', v);
this.shadowRoot.querySelector('#title').textContent = v;
}
get status() {
return this.getAttribute('status');
}
set status(v) {
this.setAttribute('status', v);
this.shadowRoot.querySelector('#status').textContent = v;
}
get created_at() {
return this.getAttribute('created-at');
}
set created_at(v) {
this.setAttribute('created-at', v);
this.shadowRoot.querySelector('#created_at').textContent = v;
}
get published_at() {
return this.getAttribute('published-at');
}
set published_at(v) {
this.setAttribute('published-at', v);
this.shadowRoot.querySelector('#published_at').textContent = v;
}
});

View File

@ -7,6 +7,7 @@ customElements.define('ow-admin', class extends Component {
<style> <style>
:host { display: block; } :host { display: block; }
</style> </style>
<slot></slot>
<article-form></article-form> <article-form></article-form>
`); `);
} }

View File

@ -7,6 +7,7 @@ customElements.define('ow-articles', class extends Component {
<style> <style>
:host { display: block; } :host { display: block; }
</style> </style>
<slot></slot>
`); `);
} }
}); });

View File

@ -55,7 +55,7 @@ form > div {
display: block; display: block;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
input, textarea { input, textarea, select, option {
font-size: 16px; font-size: 16px;
border: none; border: none;

View File

@ -0,0 +1,420 @@
import { Component } from "../shared";
customElements.define('rich-text-editor', class extends Component {
#selection;
#range;
constructor() {
super(`
<style>
:host {
display: block;
}
#tools > #first-row {
display: flex;
justify-content: flex-start;
}
#edit {
border: 1px solid var(--border-slim-color);
min-height: 90px;
padding: 8px;
}
button, select {
background: none;
border: 1px solid var(--border-slim-color);
margin-right: 8px;
}
button {
padding: 4px;
width: 46px;
height: 46px;
}
select {
padding: 4px;
height: 46px;
}
svg {
width: 36px;
height: 36px;
}
section > div {
margin-bottom: 16px;
}
section ul {
list-style: circle;
}
section ol {
list-style: decimal;
}
#image input {
display: none;
}
#align, #colors, #lists, #insert {
display: flex;
justify-content: space-between;
}
#text-color input, #background-color input {
display: none;
}
</style>
<article>
<section id="tools">
<div id="first-row">
<div>
<select id="header">
<option value="normal">Normal</option>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
<option value="h4">H4</option>
<option value="h5">H5</option>
</select>
</div>
<div id="colors">
<button id="text-color" title="Kolor czcionki">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 278 278" xml:space="preserve">
<path d="M254.833 0h-231C15.549 0 8.5 6.716 8.5 15v33c0 8.284 6.716 15 15 15s15-6.716 15-15V30h69v218H89.833c-8.284 0-15 6.716-15 15s6.716 15 15 15h99c8.284 0 15-6.716 15-15s-6.716-15-15-15H170.5V30h69v18c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.383-15-14.667-15z"/>
</svg>
<input type="color" value="#000" />
</button>
<button id="background-color" title="Kolor tła">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" xml:space="preserve">
<path d="m25.9 16 4.3 10h-9l3.9-10h.8zM48 8v36c0 2.2-1.8 4-4 4H8c-2.2 0-4-1.8-4-4V8c0-2.2 1.8-4 4-4h36c2.2 0 4 1.8 4 4zm-5.5 32.7L30.5 11c-.3-.6-.8-1-1.5-1h-7.1c-.6 0-1.2.4-1.4 1l-11 29.7c-.2.6.2 1.3.9 1.3h4.1c.6 0 1.2-.5 1.4-1.1l3.2-8.9h13.4l3.5 8.9c.2.6.8 1.1 1.4 1.1h4.1c.7 0 1.2-.7 1-1.3z"/>
</svg>
<input type="color" value="#000" />
</button>
</div>
<div id="decorations">
<button id="underscore" title="Podkreślenie">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" xml:space="preserve">
<path d="M10.4 36h4.1c.6 0 1.2-.5 1.4-1.1l3.2-8.9h13.4l3.5 8.9c.2.6.8 1.1 1.4 1.1h4.1c.7 0 1.2-.7.9-1.3L30.4 5c-.2-.6-.7-1-1.3-1H22c-.6 0-1.2.4-1.4 1l-11 29.7c-.3.6.2 1.3.8 1.3zm14.7-26h.9l4.3 10h-9l3.8-10zM48.5 42h-45c-.8 0-1.5.7-1.5 1.5v3c0 .8.7 1.5 1.5 1.5h45c.8 0 1.5-.7 1.5-1.5v-3c0-.8-.7-1.5-1.5-1.5z"/>
</svg>
</button>
<button id="superscript" title="Indeks górny">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M11 7v13H9V7H3V5h12v2h-4zm8.55-.42a.8.8 0 1 0-1.32-.36l-1.154.33A2.001 2.001 0 0 1 19 4a2 2 0 0 1 1.373 3.454L18.744 9H21v1h-4V9l2.55-2.42z"/>
</svg>
</button>
<button id="subscription" title="Indeks dolny">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.488 4.43a.75.75 0 0 1 .082 1.058L10.988 12l5.032 5.871c-.302.41-.555.906-.682 1.51L10 13.151l-5.43 6.336a.75.75 0 0 1-1.14-.976L9.013 12 3.431 5.488a.75.75 0 0 1 1.139-.976L10 10.848l5.43-6.336a.75.75 0 0 1 1.058-.081ZM17.75 15.523c0-.528.444-1.023.986-1.023.407 0 .735.19.893.434.136.21.218.566-.093 1.095-.15.255-.376.482-.682.724-.152.12-.316.237-.493.363l-.074.052c-.152.107-.315.222-.472.34-.744.56-1.565 1.346-1.565 2.742 0 .414.336.75.75.75h3.451a.75.75 0 0 0 0-1.5h-2.513c.16-.282.423-.525.779-.793.137-.103.279-.203.432-.312l.078-.054c.178-.127.37-.264.557-.41.372-.295.76-.658 1.045-1.142.557-.948.546-1.921.058-2.672C20.42 13.4 19.59 13 18.736 13c-1.478 0-2.486 1.278-2.486 2.523a.75.75 0 0 0 1.5 0Z" fill="#212121"/>
</svg>
</button>
</div>
<div id="lists">
<button id="ordered-list" title="Lista uporządkowana">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 317.109 317.109" xml:space="preserve">
<path d="M102.109 53.555h200c8.284 0 15-6.716 15-15s-6.716-15-15-15h-200c-8.284 0-15 6.716-15 15s6.716 15 15 15zM302.109 83.555h-200c-8.284 0-15 6.716-15 15s6.716 15 15 15h200c8.284 0 15-6.716 15-15s-6.715-15-15-15zM302.109 143.555h-200c-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15h200c8.284 0 15-6.716 15-15 0-8.285-6.715-15-15-15zM302.109 263.555h-200c-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15h200c8.284 0 15-6.716 15-15 0-8.284-6.715-15-15-15zM302.109 203.555h-200c-8.284 0-15 6.716-15 15 0 8.284 6.716 15 15 15h200c8.284 0 15-6.716 15-15 0-8.285-6.715-15-15-15zM17.826 49.036V86.6c0 4.074 3.32 7.146 7.724 7.146 4.33 0 7.721-3.139 7.721-7.146V30.425c0-3.96-3.247-7.062-7.392-7.062-3.647 0-5.471 2.447-6.07 3.251a6.54 6.54 0 0 0-.074.102l-6.526 9.233c-1.267 1.378-2.394 3.582-2.394 5.696-.001 4.03 3.133 7.317 7.011 7.391zM7.63 193.746h29.406c3.849 0 6.981-3.391 6.981-7.559 0-4.124-3.131-7.479-6.981-7.479H15.684v-.122c0-2.246 5.148-5.878 9.285-8.797 8.229-5.807 18.47-13.033 18.47-25.565 0-11.893-9.216-20.86-21.438-20.86-11.703 0-20.527 8.044-20.527 18.711 0 6.19 4.029 8.387 7.479 8.387 4.938 0 7.889-3.676 7.889-7.23 0-2.21.568-4.746 4.994-4.746 5.979 0 6.151 5.298 6.151 5.902 0 4.762-6.18 9.213-12.157 13.519C8.442 163.228.068 169.26.068 178.587v8.011c-.001 4.276 3.91 7.148 7.562 7.148zM42.446 242.783c0-12.342-7.288-19.42-19.994-19.42-16.66 0-21.062 11.898-21.062 18.189 0 7.325 5.445 8.115 7.786 8.115 4.559 0 7.621-3.063 7.621-7.622 0-1.753.624-3.766 5.487-3.766 3.495 0 4.918.503 4.918 5.568 0 4.948-1.062 5.487-5.245 5.487-4.018 0-7.047 3.17-7.047 7.375 0 4.159 3.066 7.295 7.131 7.295 5.525 0 6.635 2.256 6.635 5.897v1.558c0 6.126-2.389 7.288-6.798 7.288-6.083 0-6.556-3.133-6.556-4.093 0-3.631-2.407-7.294-7.785-7.294-4.72 0-7.538 2.942-7.538 7.869 0 8.976 7.696 18.516 21.958 18.516 13.854 0 22.126-8.331 22.126-22.286v-1.558c0-5.722-1.83-10.465-5.264-13.876 2.352-3.403 3.627-7.944 3.627-13.242z"/>
</svg>
</button>
<button id="unordered-list" title="Lista nieuporządkowana">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 487.3 487.3" xml:space="preserve">
<path d="M487.2 69.7c0 12.9-10.5 23.4-23.4 23.4h-322c-12.9 0-23.4-10.5-23.4-23.4s10.5-23.4 23.4-23.4h322.1c12.9.1 23.3 10.5 23.3 23.4zm-23.3 92.6H141.8c-12.9 0-23.4 10.5-23.4 23.4s10.5 23.4 23.4 23.4h322.1c12.9 0 23.4-10.5 23.4-23.4-.1-12.9-10.5-23.4-23.4-23.4zm0 116H141.8c-12.9 0-23.4 10.5-23.4 23.4s10.5 23.4 23.4 23.4h322.1c12.9 0 23.4-10.5 23.4-23.4-.1-12.9-10.5-23.4-23.4-23.4zm0 116H141.8c-12.9 0-23.4 10.5-23.4 23.4s10.5 23.4 23.4 23.4h322.1c12.9 0 23.4-10.5 23.4-23.4-.1-12.9-10.5-23.4-23.4-23.4zM38.9 30.8C17.4 30.8 0 48.2 0 69.7s17.4 39 38.9 39 38.9-17.5 38.9-39-17.4-38.9-38.9-38.9zm0 116C17.4 146.8 0 164.2 0 185.7s17.4 38.9 38.9 38.9 38.9-17.4 38.9-38.9-17.4-38.9-38.9-38.9zm0 116C17.4 262.8 0 280.2 0 301.7s17.4 38.9 38.9 38.9 38.9-17.4 38.9-38.9-17.4-38.9-38.9-38.9zm0 115.9C17.4 378.7 0 396.1 0 417.6s17.4 38.9 38.9 38.9 38.9-17.4 38.9-38.9c0-21.4-17.4-38.9-38.9-38.9z"/>
</svg>
</button>
</div>
<div id="insert">
<button id="image" title="Wstaw obrazek">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 58 58" xml:space="preserve">
<path d="M31 56h24V32H31v24zm2-22h20v20h-9V41.414l4.293 4.293 1.414-1.414L43 37.586l-6.707 6.707 1.414 1.414L42 41.414V54h-9V34zM21.569 13.569C21.569 10.498 19.071 8 16 8s-5.569 2.498-5.569 5.569c0 3.07 2.498 5.568 5.569 5.568s5.569-2.497 5.569-5.568zm-9.138 0C12.431 11.602 14.032 10 16 10s3.569 1.602 3.569 3.569-1.601 3.569-3.569 3.569-3.569-1.601-3.569-3.569zM6.25 36.661a.997.997 0 0 0 1.41.09l16.313-14.362 7.319 7.318a.999.999 0 1 0 1.414-1.414l-1.825-1.824 9.181-10.054 11.261 10.323a1 1 0 0 0 1.351-1.475l-12-11a1.002 1.002 0 0 0-1.414.063l-9.794 10.727-4.743-4.743a1.003 1.003 0 0 0-1.368-.044L6.339 35.249a1 1 0 0 0-.089 1.412z"/><path d="M57 2H1a1 1 0 0 0-1 1v44a1 1 0 0 0 1 1h24a1 1 0 1 0 0-2H2V4h54v23a1 1 0 1 0 2 0V3a1 1 0 0 0-1-1z"/>
</svg>
<input type="file" accept="image/*" />
</button>
</div>
<div id="align">
<button id="align-left" title="Wyrównaj do lewej">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200" xml:space="preserve">
<path d="M618.75 99.202v178.006H0V99.202h618.75zm328.125 274.53v178.006H0V373.732h946.875zM731.25 648.262v178.006H0V648.262h731.25zM1200 922.792v178.006H0V922.792h1200z"/>
</svg>
</button>
<button id="align-center" title="Wyrównaj do środka">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 31.668 31.668" xml:space="preserve">
<path d="M25.501 5H6.167V0h19.334v5zM.168 8.889v5H31.5v-5H.168zm5.999 8.888v5h19.334v-5H6.167zM.168 31.668H31.5v-5H.168v5z"/>
</svg>
</button>
<button id="align-right" title="Wyrównaj do prawej">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200" xml:space="preserve">
<path d="M581.25 99.202v178.006H1200V99.202H581.25zm-328.125 274.53v178.006H1200V373.732H253.125zm215.625 274.53v178.006H1200V648.262H468.75zM0 922.792v178.006h1200V922.792H0z"/>
</svg>
</button>
<button id="align-justify" title="Wyrównaj do lewej i prawej">
<svg viewBox="-32 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path d="M432 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"/>
</svg>
</button>
</div>
</div>
<div id="second-row">
</div>
</section>
<section id="edit" contenteditable="true"></section>
</article>
`);
const header = this.shadowRoot.querySelector('#tools #header');
header.addEventListener('click', () => {
this.#saveSelection();
});
header.addEventListener('change', ev => {
switch (ev.target.value) {
case 'normal':
return this.#removeWrapper();
case 'h1':
return this.#wrapNode('H1');
case 'h2':
return this.#wrapNode('H2');
case 'h3':
return this.#wrapNode('H3');
case 'h4':
return this.#wrapNode('H4');
case 'h5':
return this.#wrapNode('H5');
}
});
this.shadowRoot.querySelector('#ordered-list').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#wrapNode('ol', 'li');
});
{
let timeout = null;
this.shadowRoot.querySelector('#edit').addEventListener('keyup', (ev) => {
ev.stopPropagation();
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
this.#emitChange();
}, 1000 / 3);
});
}
this.shadowRoot.querySelector('#unordered-list').addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#wrapNode('ul', 'li');
});
const imgBtn = this.shadowRoot.querySelector('#image');
const imgInput = imgBtn.querySelector('input');
imgInput.addEventListener('click', ev => {
ev.stopPropagation();
});
imgInput.addEventListener('change', ev => {
ev.stopPropagation();
const file = imgInput.files[0];
const reader = new FileReader();
reader.onloadend = () => {
const selected = this.#selected;
if (!this.constructor.#isEditNode(selected)) return;
let el = selected;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
if (!el) return;
const img = new Image();
img.src = reader.result || '';
el.appendChild(img);
};
reader.readAsDataURL(file);
});
imgBtn.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
imgInput.click();
});
{
const el = this.shadowRoot.querySelector("#align-justify");
el.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
this.#setStyle('textAlign', 'justify');
});
}
{
const el = this.shadowRoot.querySelector("#align-left");
el.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
this.#setStyle('textAlign', 'left');
});
}
{
const el = this.shadowRoot.querySelector("#align-center");
el.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
this.#setStyle('textAlign', 'center');
});
}
{
const el = this.shadowRoot.querySelector("#align-right");
el.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
this.#saveSelection();
this.#setStyle('textAlign', 'right');
});
}
{
const button = this.shadowRoot.querySelector("#text-color");
const input = button.querySelector('input');
input.addEventListener('click', ev => ev.stopPropagation());
input.addEventListener('change', ev => {
ev.stopPropagation();
this.#setStyle('color', input.value);
});
button.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
input.click();
this.#saveSelection();
});
}
{
const button = this.shadowRoot.querySelector("#background-color");
const input = button.querySelector('input');
input.addEventListener('click', ev => ev.stopPropagation());
input.addEventListener('change', ev => {
ev.stopPropagation();
this.#setStyle('backgroundColor', input.value);
});
button.addEventListener('click', ev => {
ev.stopPropagation();
ev.preventDefault();
input.click();
this.#saveSelection();
});
}
{
const el = this.shadowRoot.querySelector('#underscore');
el.addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#setStyle("textDecoration", 'underline');
});
}
{
const el = this.shadowRoot.querySelector('#superscript');
el.addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#wrapNode('sup');
});
}
{
const el = this.shadowRoot.querySelector('#subscription');
el.addEventListener('click', ev => {
ev.stopPropagation();
this.#saveSelection();
this.#wrapNode('sub');
});
}
}
static #isEditNode(el) {
while (true) {
if (!el) return false;
if (el.nodeType === Node.TEXT_NODE) {
el = el.parentElement;
continue;
}
if (!el.tagName) return false;
if (el.tagName.toLocaleLowerCase() === 'body') return false;
if (el.id === 'edit') return true;
el = el.parentElement;
}
}
#wrapNode(...tags) {
const selected = this.#selected;
if (!this.constructor.#isEditNode(selected)) return;
let el = selected;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
if (!el) return;
for (let i = 0; i < tags.length; i++) {
const tag = tags[i];
const newNode = document.createElement(tag);
el.appendChild(newNode);
el = newNode;
if (i + 1 === tags.length) {
el.appendChild(selected);
}
el.focus();
}
this.#restoreSelection();
return el;
}
#removeWrapper() {
const el = this.#selected;
if (!this.constructor.#isEditNode(el)) return;
const headerNode = el.parentElement;
if (!headerNode) return;
if (!headerNode.tagName.toLocaleLowerCase().startsWith('h')) {
return;
}
const parent = headerNode.parentElement;
parent.replaceChild(el, headerNode);
this.#restoreSelection();
}
#setStyle(setter, value) {
const s = this.#selected;
if (!this.constructor.#isEditNode(s)) return;
let el = s;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
if (el.id === 'edit') {
const div = el.appendChild(document.createElement('div'));
div.appendChild(s);
el = div;
}
if (el.style[setter] === value) {
el.style[setter] = null;
} else {
el.style[setter] = value;
}
this.#restoreSelection();
}
#saveSelection() {
this.#selection = window.getSelection();
this.#range = this.#selection.getRangeAt(0);
this.#selection.removeAllRanges();
this.shadowRoot.querySelector('#edit').blur();
}
#restoreSelection() {
this.shadowRoot.querySelector('#edit').blur();
this.shadowRoot.querySelector('#edit').click();
this.shadowRoot.querySelector('#edit').focus();
if (this.#range) {
const s = window.getSelection();
s.removeAllRanges();
s.addRange(this.#range);
}
this.#emitChange();
}
get #selected() {
return this.#range.startContainer
}
get value() {
return this.shadowRoot.querySelector('#edit').innerHTML;
}
set value(html) {
this.shadowRoot.querySelector('#edit').innerHTML = html;
}
#emitChange() {
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
}
});

View File

@ -1 +1,15 @@
-- Add migration script here CREATE TYPE "NewsStatus" AS ENUM (
'Pending',
'Published',
'Hidden'
);
CREATE TABLE news
(
id serial unique not null primary key,
title text not null unique,
body text not null,
status "NewsStatus" not null default 'Pending',
published_at timestamp not null default now(),
created_at timestamp not null default now()
);

View File

@ -49,6 +49,24 @@ impl LocalBusinessState {
} }
} }
#[derive(Debug, Copy, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "snake_case")]
pub enum NewsStatus {
Pending,
Published,
Hidden,
}
impl NewsStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Pending => "Pending",
Self::Published => "Published",
Self::Hidden => "Hidden",
}
}
}
#[derive(Debug, Serialize, Deserialize, FromRow)] #[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Token { pub struct Token {
pub id: i32, pub id: i32,
@ -82,6 +100,23 @@ pub struct LocalBusinessItem {
pub picture_url: String, pub picture_url: String,
} }
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct NewsArticle {
pub id: i32,
pub title: String,
pub body: String,
pub status: NewsStatus,
pub published_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
}
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct CreateNewsArticleInput {
pub title: String,
pub body: String,
pub status: NewsStatus,
}
#[derive(Debug)] #[derive(Debug)]
pub struct CreateLocalBusinessItemInput { pub struct CreateLocalBusinessItemInput {
pub local_business_id: i32, pub local_business_id: i32,

View File

@ -7,6 +7,8 @@ pub enum Page {
LocalBusinesses, LocalBusinesses,
News, News,
Account, Account,
Admin,
AdminCreateNews,
Register, Register,
Login, Login,
BusinessItems, BusinessItems,
@ -114,3 +116,10 @@ pub struct MoveBusinessItemInput {
pub id: i32, pub id: i32,
pub item_order: i32, pub item_order: i32,
} }
#[derive(Deserialize)]
pub struct CreateNewsInput {
pub title: String,
pub body: String,
pub status: db::NewsStatus,
}

View File

@ -3,6 +3,7 @@ use std::cmp::Ordering;
use tracing::error; use tracing::error;
use crate::model::db; use crate::model::db;
use crate::model::db::NewsArticle;
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
@ -33,6 +34,11 @@ pub enum Error {
Item { Item {
item_id: i32, item_id: i32,
}, },
AllNews,
PublishedNews,
CreateNewsArticle {
input: db::CreateNewsArticleInput,
},
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@ -419,3 +425,80 @@ WHERE email = $1
Error::AccountByEmail { email } Error::AccountByEmail { email }
}) })
} }
pub async fn all_news(t: &mut T<'_>) -> Result<Vec<NewsArticle>> {
sqlx::query_as(
r#"
SELECT
id,
title,
body,
status,
published_at,
created_at
FROM
news
"#,
)
.fetch_all(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(e);
Error::AllNews
})
}
pub async fn published_news(t: &mut T<'_>) -> Result<Vec<NewsArticle>> {
sqlx::query_as(
r#"
SELECT
id,
title,
body,
status,
published_at,
created_at
FROM
news
WHERE
status = 'Published'
"#,
)
.fetch_all(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(e);
Error::PublishedNews
})
}
pub async fn create_news_article(
t: &mut T<'_>,
input: db::CreateNewsArticleInput,
) -> Result<NewsArticle> {
sqlx::query_as(
r#"
INSERT INTO news (title, body, status)
VALUES ($1, $2, $3)
RETURNING
id,
title,
body,
status,
published_at,
created_at
"#,
)
.bind(&input.title)
.bind(&input.body)
.bind(input.status)
.fetch_one(t)
.await
.map_err(|e| {
error!("{e}");
dbg!(e);
Error::CreateNewsArticle { input }
})
}

View File

@ -82,6 +82,7 @@ pub type JsonResult<T> = std::result::Result<T, JsonError>;
pub enum Error { pub enum Error {
Unauthorized, Unauthorized,
UploadFailed, UploadFailed,
Forbidden,
OwnedBusinessNotFound { account_id: i32 }, OwnedBusinessNotFound { account_id: i32 },
OwnedBusinessItemNotFound { account_id: i32, business_id: i32 }, OwnedBusinessItemNotFound { account_id: i32, business_id: i32 },
DatabaseQuery, DatabaseQuery,
@ -118,6 +119,7 @@ impl Display for Error {
Error::DatabaseQuery => { Error::DatabaseQuery => {
f.write_str("Problem z zapisaniem zmian. Proszę spróbować później") f.write_str("Problem z zapisaniem zmian. Proszę spróbować później")
} }
Error::Forbidden => f.write_str("Tylko admin może wejść na tę stronę"),
} }
} }
} }
@ -130,6 +132,7 @@ impl ResponseError for Error {
Error::OwnedBusinessItemNotFound { .. } => StatusCode::BAD_REQUEST, Error::OwnedBusinessItemNotFound { .. } => StatusCode::BAD_REQUEST,
Error::UploadFailed => StatusCode::BAD_REQUEST, Error::UploadFailed => StatusCode::BAD_REQUEST,
Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR, Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR,
Error::Forbidden => StatusCode::FORBIDDEN,
} }
} }
} }
@ -180,6 +183,7 @@ impl ResponseError for JsonError {
Error::OwnedBusinessItemNotFound { .. } => StatusCode::BAD_REQUEST, Error::OwnedBusinessItemNotFound { .. } => StatusCode::BAD_REQUEST,
Error::UploadFailed => StatusCode::BAD_REQUEST, Error::UploadFailed => StatusCode::BAD_REQUEST,
Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR, Error::DatabaseQuery => StatusCode::INTERNAL_SERVER_ERROR,
Error::Forbidden => StatusCode::FORBIDDEN,
} }
} }
} }

View File

@ -60,7 +60,7 @@ async fn handle_business_items_page(
items, items,
}; };
Ok(HttpResponse::Ok() Ok(HttpResponse::Ok()
.append_header(("Content-Type", "text/html")) .content_type("text/html")
.body(page.render().unwrap())) .body(page.render().unwrap()))
} }
@ -267,12 +267,21 @@ mod admin {
use actix_web::{get, post, web, HttpResponse}; use actix_web::{get, post, web, HttpResponse};
use askama::*; use askama::*;
use sqlx::PgPool; use sqlx::PgPool;
use tracing::{error, info};
use crate::model::view::Page; use crate::model::view::Page;
use crate::model::{db, view}; use crate::model::{db, view};
use crate::routes::{Error, Identity, Result}; use crate::queries;
use crate::{queries, routes}; use crate::routes::{Identity, Result};
macro_rules! require_admin {
($t: expr, $id: expr) => {{
let account = authorize!(&mut $t, $id);
if account.account_type == crate::model::db::AccountType::Admin {
return Err(crate::routes::Error::Forbidden);
}
account
}};
}
#[derive(Debug, Template)] #[derive(Debug, Template)]
#[template(path = "admin_panel.html")] #[template(path = "admin_panel.html")]
@ -280,25 +289,80 @@ mod admin {
page: view::Page, page: view::Page,
error: Option<String>, error: Option<String>,
account: Option<db::Account>, account: Option<db::Account>,
news: Vec<db::NewsArticle>,
} }
#[get("")] #[get("")]
async fn admin() -> HttpResponse { async fn admin(db: Data<PgPool>, id: Identity) -> Result<HttpResponse> {
HttpResponse::Ok() let pool = db.into_inner();
.append_header(("Content-Type", "text/html")) let mut t = crate::ok_or_internal!(pool.begin().await);
.body( let _account = require_admin!(&mut t, id);
let news = queries::all_news(&mut t).await.unwrap_or_default();
t.commit().await.ok();
Ok(HttpResponse::Ok().content_type("text/html").body(
AdminTemplate {
page: Page::Admin,
error: None,
account: None,
news,
}
.render()
.unwrap(),
))
}
#[post("/create")]
async fn create_news(
db: Data<PgPool>,
id: Identity,
form: Form<view::CreateNewsInput>,
) -> Result<HttpResponse> {
let form = form.into_inner();
let pool = db.into_inner();
let mut t = crate::ok_or_internal!(pool.begin().await);
let _account = require_admin!(&mut t, id);
if let Err(e) = queries::create_news_article(
&mut t,
db::CreateNewsArticleInput {
title: form.title,
body: form.body,
status: form.status,
},
)
.await
{
dbg!(e);
t.rollback().await.ok();
return Ok(HttpResponse::BadRequest().content_type("text/html").body(
AdminTemplate { AdminTemplate {
page: Page::Account, page: Page::AdminCreateNews,
error: None, error: Some("Failed".into()),
account: None, account: None,
news: vec![],
} }
.render() .render()
.unwrap(), .unwrap(),
) ));
}
t.commit().await.ok();
Ok(HttpResponse::SeeOther()
.append_header(("Location", "/admin"))
.finish())
} }
pub fn configure(config: &mut ServiceConfig) { pub fn configure(config: &mut ServiceConfig) {
config.service(web::scope("/admin").service(admin)); config.service(
web::scope("/admin")
.service(web::scope("/news").service(create_news))
.service(admin),
);
} }
} }

View File

@ -13,7 +13,7 @@ use tracing::*;
use crate::model::db; use crate::model::db;
use crate::model::view::{self, Page}; use crate::model::view::{self, Page};
use crate::routes::{Error, Identity, JsonResult, Result}; use crate::routes::{Error, Identity, JsonResult, Result};
use crate::{queries, routes, utils}; use crate::{queries, utils};
#[derive(Template)] #[derive(Template)]
#[template(path = "index.html")] #[template(path = "index.html")]
@ -26,18 +26,16 @@ pub struct IndexTemplate {
#[tracing::instrument] #[tracing::instrument]
pub async fn render_index() -> HttpResponse { pub async fn render_index() -> HttpResponse {
HttpResponse::NotFound() HttpResponse::NotFound().content_type("text/html").body(
.append_header(("Content-Type", "text/html")) IndexTemplate {
.body( services: vec![],
IndexTemplate { account: None,
services: vec![], error: None,
account: None, page: Page::LocalBusinesses,
error: None, }
page: Page::LocalBusinesses, .render()
} .unwrap(),
.render() )
.unwrap(),
)
} }
#[get("/")] #[get("/")]
@ -112,9 +110,7 @@ ORDER BY item_order ASC
t.commit().await.ok(); t.commit().await.ok();
Ok(HttpResponse::Ok() Ok(HttpResponse::Ok().content_type("text/html").body(body))
.append_header(("Content-Type", "text/html"))
.body(body))
} }
#[derive(Template)] #[derive(Template)]
@ -333,40 +329,34 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
error!("{e} {:?}", dir); error!("{e} {:?}", dir);
dbg!(e); dbg!(e);
t.rollback().await.unwrap(); t.rollback().await.unwrap();
return HttpResponse::BadRequest() return HttpResponse::BadRequest().content_type("text/html").body(
.append_header(("Content-Type", "text/html")) AccountTemplate {
.body( account: None,
AccountTemplate { error: Some(
account: None, "Problem z utworzeniem konta. Nie można zapisać zdjęcia.".into(),
error: Some( ),
"Problem z utworzeniem konta. Nie można zapisać zdjęcia." page: Page::Register,
.into(), }
), .render()
page: Page::Register, .unwrap(),
} );
.render()
.unwrap(),
);
} }
let path = dir.join(name); let path = dir.join(name);
if let Err(e) = std::fs::rename(format!(".{}", item.picture_url), &path) { if let Err(e) = std::fs::rename(format!(".{}", item.picture_url), &path) {
error!("{e} {:?}", item.picture_url); error!("{e} {:?}", item.picture_url);
dbg!(e); dbg!(e);
t.rollback().await.unwrap(); t.rollback().await.unwrap();
return HttpResponse::BadRequest() return HttpResponse::BadRequest().content_type("text/html").body(
.append_header(("Content-Type", "text/html")) AccountTemplate {
.body( account: None,
AccountTemplate { error: Some(
account: None, "Problem z utworzeniem konta. Nie można zapisać zdjęcia.".into(),
error: Some( ),
"Problem z utworzeniem konta. Nie można zapisać zdjęcia." page: Page::Register,
.into(), }
), .render()
page: Page::Register, .unwrap(),
} );
.render()
.unwrap(),
);
} }
let path = path.to_str().map(String::from).unwrap_or_default(); let path = path.to_str().map(String::from).unwrap_or_default();
path.strip_prefix('.') path.strip_prefix('.')
@ -381,17 +371,15 @@ RETURNING id, local_business_id, name, price, item_order, picture_url
tracing::error!("{e}"); tracing::error!("{e}");
dbg!(e); dbg!(e);
t.rollback().await.unwrap(); t.rollback().await.unwrap();
return HttpResponse::BadRequest() return HttpResponse::BadRequest().content_type("text/html").body(
.append_header(("Content-Type", "text/html")) AccountTemplate {
.body( account: None,
AccountTemplate { error: Some("Problem z utworzeniem konta".into()),
account: None, page: Page::Register,
error: Some("Problem z utworzeniem konta".into()), }
page: Page::Register, .render()
} .unwrap(),
.render() );
.unwrap(),
);
} }
} }
} }
@ -476,17 +464,15 @@ async fn login(form: web::Form<LoginForm>, db: Data<PgPool>, id: Identity) -> Re
id.remember(format!("{}", record.id)); id.remember(format!("{}", record.id));
t.commit().await.ok(); t.commit().await.ok();
Ok(HttpResponse::Ok() Ok(HttpResponse::Ok().content_type("text/html").body(
.append_header(("Content-Type", "text/html")) AccountTemplate {
.body( account: Some(record),
AccountTemplate { error: None,
account: Some(record), page: Page::Login,
error: None, }
page: Page::Login, .render()
} .unwrap(),
.render() ))
.unwrap(),
))
} }
#[derive(Serialize)] #[derive(Serialize)]