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
|
/target
|
||||||
|
/uploads
|
||||||
/config.local.toml
|
/config.local.toml
|
||||||
|
|
|
@ -5,7 +5,7 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.41.0", features = ["full"] }
|
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" ] }
|
axum-extra = { version = "0.9.4", features = [ "cookie" ] }
|
||||||
tera = "1.20.0"
|
tera = "1.20.0"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
url = "http://localhost:4000"
|
url = "http://localhost:4000"
|
||||||
port = 4000
|
port = 4000
|
||||||
|
uploads_dir = "uploads"
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
url = "postgres://comfycamp:simple-password@localhost/comfycamp"
|
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 images;
|
||||||
|
pub mod services;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
||||||
pub async fn apply_migrations(pool: &sqlx::PgPool) -> Result<(), sqlx::migrate::MigrateError> {
|
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::{
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
@ -45,8 +46,23 @@ async fn main() {
|
||||||
"/api/images/:image_id",
|
"/api/images/:image_id",
|
||||||
get(controllers::get_image).delete(controllers::delete_image),
|
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"))
|
.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 addr = format!("0.0.0.0:{}", CONFIG.port);
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
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;
|
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 {
|
.service .tag {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
|
@ -23,13 +36,22 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service .links {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.service .link {
|
.service .link {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service .link img {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
.service svg {
|
.service svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 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 axum_extra::extract::CookieJar;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::internal::db::services;
|
||||||
use crate::web::templates;
|
use crate::web::templates;
|
||||||
|
|
||||||
pub async fn home(
|
pub async fn home(
|
||||||
cookies: CookieJar,
|
cookies: CookieJar,
|
||||||
State(state): State<Arc<super::AppState>>,
|
State(state): State<Arc<super::AppState>>,
|
||||||
) -> Result<Html<String>, super::ControllerError> {
|
) -> 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);
|
let resp = templates::render("home.html", &ctx);
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod ctx;
|
mod ctx;
|
||||||
mod error;
|
mod error;
|
||||||
|
@ -5,6 +6,7 @@ mod home;
|
||||||
mod images;
|
mod images;
|
||||||
mod openid;
|
mod openid;
|
||||||
|
|
||||||
|
pub use admin::*;
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use home::*;
|
pub use home::*;
|
||||||
pub use images::{delete_image, get_image, upload_image};
|
pub use images::{delete_image, get_image, upload_image};
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use tera::Tera;
|
use std::collections::HashMap;
|
||||||
|
use tera::{Error, Tera, Value};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref TEMPLATES: Tera = {
|
pub static ref TEMPLATES: Tera = {
|
||||||
|
@ -12,6 +14,25 @@ lazy_static! {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tera.autoescape_on(vec![".html"]);
|
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
|
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>
|
<p><i>- Иван, администратор comfycamp.space</i></p>
|
||||||
|
|
||||||
|
{% for svc in services %}
|
||||||
<div class="service">
|
<div class="service">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<h3>Mastodon</h3>
|
<h3>{{ svc.name }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<a class="link" href="https://m.comfycamp.space" target="_blank">
|
{% if svc.cover_id %}
|
||||||
m.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
|
<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>
|
</a>
|
||||||
<p>
|
{% else %}
|
||||||
Микроблоги с поддержкой 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>
|
|
||||||
<a class="link">
|
<a class="link">
|
||||||
matrix.comfycamp.space
|
{{ svc.url }}
|
||||||
</a>
|
</a>
|
||||||
<p>
|
{% endif %}
|
||||||
Современный протокол для общения.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="service">
|
{% if svc.tor_url %}
|
||||||
<div class="title">
|
<a class="link" href="{{ svc.tor_url }}" target="_blank">
|
||||||
<h3>Forgejo</h3>
|
Onion <img src="/assets/icons/external.svg" width="16" height="16" alt="" />
|
||||||
</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="" />
|
|
||||||
</a>
|
</a>
|
||||||
<p>
|
{% endif %}
|
||||||
Хостинг для git-проектов.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p>{{ svc.short_description }}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Главная</a>
|
<a href="/">Главная</a>
|
||||||
|
{% if user and user.is_admin %}
|
||||||
|
<a href="/admin">Админка</a>
|
||||||
|
{% endif %}
|
||||||
<div class="space"></div>
|
<div class="space"></div>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
Привет, {{ user.display_name }}!
|
Привет, {{ user.display_name }}!
|
||||||
|
|
Loading…
Reference in a new issue