Admin panel for services
Store list of services in the database. Add service covers. Still needs some work. Authorization probably should be implemented in middleware.
This commit is contained in:
parent
2e25aee255
commit
4f8f045d52
21 changed files with 584 additions and 74 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
/target
|
||||
/uploads
|
||||
/config.local.toml
|
||||
|
|
|
@ -5,7 +5,7 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
tokio = { version = "1.41.0", features = ["full"] }
|
||||
axum = { version = "0.7.7", features = [ "macros", "multipart", "json" ] }
|
||||
axum = { version = "0.7.7", features = [ "macros", "multipart", "json", "form" ] }
|
||||
axum-extra = { version = "0.9.4", features = [ "cookie" ] }
|
||||
tera = "1.20.0"
|
||||
lazy_static = "1.5.0"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
url = "http://localhost:4000"
|
||||
port = 4000
|
||||
uploads_dir = "uploads"
|
||||
|
||||
[database]
|
||||
url = "postgres://comfycamp:simple-password@localhost/comfycamp"
|
||||
|
|
12
src/internal/db/migrations/0003_services.sql
Normal file
12
src/internal/db/migrations/0003_services.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE services(
|
||||
slug VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
is_url_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tor_url TEXT,
|
||||
cover_id UUID
|
||||
REFERENCES images(id)
|
||||
ON DELETE SET NULL,
|
||||
short_description TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
|
@ -1,4 +1,5 @@
|
|||
pub mod images;
|
||||
pub mod services;
|
||||
pub mod users;
|
||||
|
||||
pub async fn apply_migrations(pool: &sqlx::PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
|
|
116
src/internal/db/services.rs
Normal file
116
src/internal/db/services.rs
Normal file
|
@ -0,0 +1,116 @@
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewService {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub is_url_active: bool,
|
||||
pub tor_url: Option<String>,
|
||||
pub cover_id: Option<Uuid>,
|
||||
pub short_description: String,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize)]
|
||||
pub struct Service {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub is_url_active: bool,
|
||||
pub tor_url: Option<String>,
|
||||
pub cover_id: Option<Uuid>,
|
||||
pub short_description: String,
|
||||
}
|
||||
|
||||
pub async fn create_service(pool: &PgPool, svc: &NewService) -> Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO services(slug, name, url, is_url_active, tor_url, cover_id, short_description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
",
|
||||
)
|
||||
.bind(&svc.slug)
|
||||
.bind(&svc.name)
|
||||
.bind(&svc.url)
|
||||
.bind(svc.is_url_active)
|
||||
.bind(&svc.tor_url)
|
||||
.bind(svc.cover_id)
|
||||
.bind(&svc.short_description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_services(pool: &PgPool) -> Result<Vec<Service>> {
|
||||
let services = sqlx::query_as::<_, Service>(
|
||||
"
|
||||
SELECT *
|
||||
FROM services
|
||||
ORDER BY sort_order
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(services)
|
||||
}
|
||||
|
||||
pub async fn get_service(pool: &PgPool, slug: &str) -> Result<Service> {
|
||||
let service = sqlx::query_as::<_, Service>(
|
||||
"
|
||||
SELECT *
|
||||
FROM services
|
||||
WHERE slug = $1
|
||||
",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
pub async fn update_service(pool: &PgPool, svc: &NewService) -> Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE services SET
|
||||
name = $1,
|
||||
url = $2,
|
||||
is_url_active = $3,
|
||||
tor_url = $4,
|
||||
cover_id = $5,
|
||||
short_description = $6
|
||||
WHERE slug = $7
|
||||
",
|
||||
)
|
||||
.bind(&svc.name)
|
||||
.bind(&svc.url)
|
||||
.bind(svc.is_url_active)
|
||||
.bind(&svc.tor_url)
|
||||
.bind(svc.cover_id)
|
||||
.bind(&svc.short_description)
|
||||
.bind(&svc.slug)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_service(pool: &PgPool, slug: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
DELETE FROM services
|
||||
WHERE slug = $1
|
||||
RETURNING 1
|
||||
",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
18
src/main.rs
18
src/main.rs
|
@ -1,4 +1,5 @@
|
|||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
@ -45,8 +46,23 @@ async fn main() {
|
|||
"/api/images/:image_id",
|
||||
get(controllers::get_image).delete(controllers::delete_image),
|
||||
)
|
||||
.route("/admin", get(controllers::admin_page))
|
||||
.route("/admin/services", get(controllers::list_services))
|
||||
.route(
|
||||
"/admin/services/new",
|
||||
get(controllers::new_service).post(controllers::create_service),
|
||||
)
|
||||
.route(
|
||||
"/admin/services/:slug",
|
||||
get(controllers::edit_service).post(controllers::update_service),
|
||||
)
|
||||
.route(
|
||||
"/admin/services/:slug/delete",
|
||||
post(controllers::delete_service),
|
||||
)
|
||||
.nest_service("/assets", ServeDir::new("src/web/assets"))
|
||||
.with_state(shared_state.clone());
|
||||
.with_state(shared_state.clone())
|
||||
.layer(DefaultBodyLimit::max(1024 * 1024 * 100));
|
||||
|
||||
let addr = format!("0.0.0.0:{}", CONFIG.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
|
|
52
src/web/assets/css/admin.css
Normal file
52
src/web/assets/css/admin.css
Normal file
|
@ -0,0 +1,52 @@
|
|||
input,
|
||||
textarea {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
display: inline;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
color: white;
|
||||
outline: 1px solid transparent;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline-color: var(--input-border);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
color: white;
|
||||
}
|
|
@ -16,6 +16,19 @@
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.service .image {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.service .image img {
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
}
|
||||
|
||||
.service .tag {
|
||||
font-size: 12px;
|
||||
background-color: #333;
|
||||
|
@ -23,13 +36,22 @@
|
|||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.service .links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.service .link {
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.service .link img {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.service svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
48
src/web/assets/js/admin.js
Normal file
48
src/web/assets/js/admin.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
"use strict";
|
||||
|
||||
/**
|
||||
* Upload the first file and update service cover id field.
|
||||
*/
|
||||
async function onFileSelection(e) {
|
||||
let formData = new FormData();
|
||||
formData.append("image", event.target.files[0]);
|
||||
|
||||
const response = await fetch("/api/images", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const coverIdInput = document.getElementById("service-cover-id");
|
||||
coverIdInput.value = data.ids[0];
|
||||
coverIdInput.type = "text";
|
||||
coverIdInput.disabled = false;
|
||||
|
||||
const button = document.getElementById("clear-cover-button");
|
||||
button.style.display = "block";
|
||||
}
|
||||
|
||||
function clearCover() {
|
||||
const coverIdInput = document.getElementById("service-cover-id");
|
||||
coverIdInput.value = "";
|
||||
coverIdInput.type = "hidden";
|
||||
coverIdInput.disabled = true;
|
||||
|
||||
const button = document.getElementById("clear-cover-button");
|
||||
button.style.display = "none";
|
||||
}
|
||||
|
||||
async function submitForm(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.target.tor_url.value === "") {
|
||||
e.target.tor_url.disabled = true;
|
||||
}
|
||||
|
||||
if (!e.target.is_url_active.checked) {
|
||||
e.target.is_url_active.value = "false";
|
||||
e.target.is_url_active.checked = true;
|
||||
}
|
||||
|
||||
e.target.submit();
|
||||
}
|
18
src/web/controllers/admin/index.rs
Normal file
18
src/web/controllers/admin/index.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
use axum::{extract::State, response::IntoResponse};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::web::controllers::{get_admin_user, AppState, ControllerError};
|
||||
use crate::web::templates;
|
||||
|
||||
pub async fn admin_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
let user = get_admin_user(&state.db, cookies).await?;
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("user", &user);
|
||||
|
||||
let resp = templates::render("admin/index.html", &ctx);
|
||||
Ok(resp)
|
||||
}
|
7
src/web/controllers/admin/mod.rs
Normal file
7
src/web/controllers/admin/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
mod index;
|
||||
mod services;
|
||||
|
||||
pub use index::admin_page;
|
||||
pub use services::{
|
||||
create_service, delete_service, edit_service, list_services, new_service, update_service,
|
||||
};
|
90
src/web/controllers/admin/services.rs
Normal file
90
src/web/controllers/admin/services.rs
Normal file
|
@ -0,0 +1,90 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
Form,
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::internal::db::services;
|
||||
use crate::web::controllers::{get_admin_user, AppState, ControllerError};
|
||||
use crate::web::templates;
|
||||
|
||||
pub async fn list_services(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
let user = get_admin_user(&state.db, cookies).await?;
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("user", &user);
|
||||
|
||||
let service_list = services::get_services(&state.db).await?;
|
||||
ctx.insert("services", &service_list);
|
||||
|
||||
let resp = templates::render("admin/services/list.html", &ctx);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn new_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
let user = get_admin_user(&state.db, cookies).await?;
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("user", &user);
|
||||
|
||||
let resp = templates::render("admin/services/form.html", &ctx);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn create_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Form(svc): Form<services::NewService>,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
get_admin_user(&state.db, cookies).await?;
|
||||
|
||||
services::create_service(&state.db, &svc).await?;
|
||||
|
||||
Ok(Redirect::to("/admin/services"))
|
||||
}
|
||||
|
||||
pub async fn edit_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
let user = get_admin_user(&state.db, cookies).await?;
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("user", &user);
|
||||
|
||||
let svc = services::get_service(&state.db, &slug).await?;
|
||||
ctx.insert("svc", &svc);
|
||||
|
||||
let resp = templates::render("admin/services/form.html", &ctx);
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn update_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Form(svc): Form<services::NewService>,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
get_admin_user(&state.db, cookies).await?;
|
||||
|
||||
services::update_service(&state.db, &svc).await?;
|
||||
|
||||
Ok(Redirect::to(&format!("/admin/services/{}", svc.slug)))
|
||||
}
|
||||
|
||||
pub async fn delete_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, ControllerError> {
|
||||
get_admin_user(&state.db, cookies).await?;
|
||||
|
||||
services::delete_service(&state.db, &slug).await?;
|
||||
|
||||
Ok(Redirect::to("/admin/services"))
|
||||
}
|
|
@ -2,13 +2,18 @@ use axum::{extract::State, response::Html};
|
|||
use axum_extra::extract::CookieJar;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::internal::db::services;
|
||||
use crate::web::templates;
|
||||
|
||||
pub async fn home(
|
||||
cookies: CookieJar,
|
||||
State(state): State<Arc<super::AppState>>,
|
||||
) -> Result<Html<String>, super::ControllerError> {
|
||||
let ctx = super::get_context(&state.db, cookies).await?;
|
||||
let mut ctx = super::get_context(&state.db, cookies).await?;
|
||||
|
||||
let service_list = services::get_services(&state.db).await?;
|
||||
ctx.insert("services", &service_list);
|
||||
|
||||
let resp = templates::render("home.html", &ctx);
|
||||
Ok(resp)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
mod admin;
|
||||
mod auth;
|
||||
mod ctx;
|
||||
mod error;
|
||||
|
@ -5,6 +6,7 @@ mod home;
|
|||
mod images;
|
||||
mod openid;
|
||||
|
||||
pub use admin::*;
|
||||
pub use auth::*;
|
||||
pub use home::*;
|
||||
pub use images::{delete_image, get_image, upload_image};
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
use axum::response::Html;
|
||||
use lazy_static::lazy_static;
|
||||
use tera::Tera;
|
||||
use std::collections::HashMap;
|
||||
use tera::{Error, Tera, Value};
|
||||
use url::Url;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TEMPLATES: Tera = {
|
||||
|
@ -12,6 +14,25 @@ lazy_static! {
|
|||
}
|
||||
};
|
||||
tera.autoescape_on(vec![".html"]);
|
||||
tera.register_filter(
|
||||
"domain",
|
||||
|v: &Value, _h: &HashMap<String, Value>| -> Result<Value, Error> {
|
||||
let s = match v {
|
||||
Value::String(s) => s,
|
||||
_ => &v.to_string(),
|
||||
};
|
||||
|
||||
let url = match Url::parse(s) {
|
||||
Ok(url) => url,
|
||||
Err(err) => return Err(Error::msg(err)),
|
||||
};
|
||||
|
||||
match url.domain() {
|
||||
Some(domain) => Ok(Value::String(domain.to_string())),
|
||||
None => Err(Error::msg("domain is undefined")),
|
||||
}
|
||||
},
|
||||
);
|
||||
tera
|
||||
};
|
||||
}
|
||||
|
|
7
src/web/templates/admin/index.html
Normal file
7
src/web/templates/admin/index.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Админка</h1>
|
||||
|
||||
<a href="/admin/services">Сервисы</a>
|
||||
{% endblock %}
|
114
src/web/templates/admin/services/form.html
Normal file
114
src/web/templates/admin/services/form.html
Normal file
|
@ -0,0 +1,114 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% if svc %}
|
||||
{{ svc.name }}
|
||||
{% else %}
|
||||
Новый сервис
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
{% if svc %}
|
||||
<form method="post" action="/admin/services/{{ svc.slug }}/delete">
|
||||
<button type="submit">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" onsubmit="submitForm(event)">
|
||||
<div class="form-row">
|
||||
<label for="service-slug">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
id="service-slug"
|
||||
name="slug"
|
||||
required
|
||||
value="{% if svc %}{{ svc.slug }}{% endif %}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="service-name">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
id="service-name"
|
||||
name="name"
|
||||
required
|
||||
value="{% if svc %}{{ svc.name }}{% endif %}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="service-url">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="service-url"
|
||||
name="url"
|
||||
required
|
||||
value="{% if svc %}{{ svc.url }}{% endif %}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="service-url-checkbox">URL активна?</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="service-url-checkbox"
|
||||
name="is_url_active"
|
||||
value="true"
|
||||
{% if svc and svc.is_url_active %}checked{% endif %}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="service-tor-url">Tor URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="service-tor-url"
|
||||
name="tor_url"
|
||||
value="{% if svc %}{{ svc.tor_url }}{% endif %}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="service-cover">Обложка</label>
|
||||
<input type="file" id="service-cover" accept="image/*" onchange="onFileSelection(event)" />
|
||||
<input
|
||||
id="service-cover-id"
|
||||
name="cover_id"
|
||||
value="{% if svc %}{{ svc.cover_id }}{% endif %}"
|
||||
{% if not svc or not svc.cover_id %}
|
||||
type="hidden"
|
||||
disabled
|
||||
{% endif %}
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
id="clear-cover-button"
|
||||
onclick="clearCover()"
|
||||
type="button"
|
||||
{% if not svc or not svc.cover_id %}
|
||||
style="display: none"
|
||||
{% endif %}
|
||||
>
|
||||
Убрать обложку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="service-short-description">Короткое описание</label>
|
||||
<textarea
|
||||
id="service-short-description"
|
||||
name="short_description"
|
||||
required
|
||||
>{% if svc %}{{ svc.short_description }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<button class="submit-button" type="submit">Отправить</button>
|
||||
</form>
|
||||
{% endblock %}
|
15
src/web/templates/admin/services/list.html
Normal file
15
src/web/templates/admin/services/list.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Сервисы</h1>
|
||||
|
||||
<a href="/admin/services/new">Новый сервис</a>
|
||||
|
||||
<ul>
|
||||
{% for service in services %}
|
||||
<li>
|
||||
<a href="/admin/services/{{ service.slug }}">{{ service.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
|
@ -23,75 +23,34 @@
|
|||
|
||||
<p><i>- Иван, администратор comfycamp.space</i></p>
|
||||
|
||||
{% for svc in services %}
|
||||
<div class="service">
|
||||
<div class="title">
|
||||
<h3>Mastodon</h3>
|
||||
<h3>{{ svc.name }}</h3>
|
||||
</div>
|
||||
<a class="link" href="https://m.comfycamp.space" target="_blank">
|
||||
m.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
|
||||
{% if svc.cover_id %}
|
||||
<div class="image">
|
||||
<img src="/api/images/{{ svc.cover_id }}" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="links">
|
||||
{% if svc.is_url_active %}
|
||||
<a class="link" href="{{ svc.url }}" target="_blank">
|
||||
{{ svc.url | domain }} <img src="/assets/icons/external.svg" width="16" height="16" alt="" />
|
||||
</a>
|
||||
<p>
|
||||
Микроблоги с поддержкой fediverse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="service">
|
||||
<div class="title">
|
||||
<h3>Peertube</h3>
|
||||
</div>
|
||||
<a class="link" href="https://v.comfycamp.space" target="_blank">
|
||||
v.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
|
||||
</a>
|
||||
<p>
|
||||
Видеохостинг, альтернатива YouTube.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="service">
|
||||
<div class="title">
|
||||
<h3>Matrix</h3>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="link">
|
||||
matrix.comfycamp.space
|
||||
{{ svc.url }}
|
||||
</a>
|
||||
<p>
|
||||
Современный протокол для общения.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="service">
|
||||
<div class="title">
|
||||
<h3>Forgejo</h3>
|
||||
</div>
|
||||
<a class="link" href="https://git.comfycamp.space" target="_blank">
|
||||
git.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
|
||||
{% if svc.tor_url %}
|
||||
<a class="link" href="{{ svc.tor_url }}" target="_blank">
|
||||
Onion <img src="/assets/icons/external.svg" width="16" height="16" alt="" />
|
||||
</a>
|
||||
<p>
|
||||
Хостинг для git-проектов.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="service">
|
||||
<div class="title">
|
||||
<h3>Nextcloud</h3>
|
||||
</div>
|
||||
<a class="link" href="https://nc.comfycamp.space" target="_blank">
|
||||
nc.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
|
||||
</a>
|
||||
<p>
|
||||
Облако, календарь, задачи.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="service">
|
||||
<div class="title">
|
||||
<h3>XMPP</h3>
|
||||
</div>
|
||||
<a class="link">
|
||||
xmpp.comfycamp.space
|
||||
</a>
|
||||
<p>
|
||||
Проверенный временем протокол для общения.
|
||||
</p>
|
||||
<p>{{ svc.short_description }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -16,6 +16,9 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="/">Главная</a>
|
||||
{% if user and user.is_admin %}
|
||||
<a href="/admin">Админка</a>
|
||||
{% endif %}
|
||||
<div class="space"></div>
|
||||
{% if user %}
|
||||
Привет, {{ user.display_name }}!
|
||||
|
|
Loading…
Reference in a new issue