mirror of
https://git.alemi.dev/guestbook.rs.git
synced 2024-12-18 18:54:51 +01:00
feat: integrated frontend
also fix `$ guestbook default` and refactored a little
This commit is contained in:
parent
6fc27b47db
commit
6ad5bc86bb
10 changed files with 464 additions and 28 deletions
12
Cargo.toml
12
Cargo.toml
|
@ -20,19 +20,19 @@ async-trait = "0.1.73"
|
|||
html-escape = "0.2.13"
|
||||
clap = { version = "4.4.6", features = ["derive"] }
|
||||
tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] }
|
||||
axum = "0.6.20"
|
||||
axum = "0.7.3"
|
||||
# db providers
|
||||
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "any"] }
|
||||
# notification providers
|
||||
teloxide = { version = "0.12.2", features = ["macros"], optional = true }
|
||||
mail-send = { version = "0.4.6", optional = true }
|
||||
tokio-rustls = { version = "0.25.0", optional = true }
|
||||
# frontend
|
||||
sailfish = { version = "0.8.3", optional = true }
|
||||
axum-extra = "0.9.1"
|
||||
|
||||
[features]
|
||||
default = [
|
||||
"mysql", "sqlite", "postgres",
|
||||
"telegram", "email"
|
||||
]
|
||||
default = ["mysql", "sqlite", "postgres", "telegram", "email", "web"] # all features by default
|
||||
# db drivers
|
||||
mysql = ["sqlx/mysql"]
|
||||
sqlite = ["sqlx/sqlite"]
|
||||
|
@ -40,3 +40,5 @@ postgres = ["sqlx/postgres"]
|
|||
# notifier providers
|
||||
telegram = ["dep:teloxide"]
|
||||
email = ["dep:mail-send", "dep:tokio-rustls"]
|
||||
# frontend
|
||||
web = ["dep:sailfish"]
|
||||
|
|
1
sailfish.toml
Normal file
1
sailfish.toml
Normal file
|
@ -0,0 +1 @@
|
|||
template_dirs = ["web"]
|
|
@ -5,6 +5,8 @@ pub struct Config {
|
|||
pub overrides: ConfigOverrides,
|
||||
|
||||
pub notifiers: ConfigNotifiers,
|
||||
|
||||
pub template: ConfigTemplate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
@ -17,6 +19,55 @@ pub struct ConfigOverrides {
|
|||
pub date: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConfigTemplate {
|
||||
pub title: String,
|
||||
pub logo: String,
|
||||
pub description: String,
|
||||
pub placeholder_body: String,
|
||||
pub canonical: Option<String>,
|
||||
pub palette: ConfigPalette,
|
||||
}
|
||||
|
||||
impl Default for ConfigTemplate {
|
||||
fn default() -> Self {
|
||||
ConfigTemplate {
|
||||
title: "guestbook.rs".into(),
|
||||
logo: "https://cdn.alemi.dev/social/someriver.jpg".into(),
|
||||
description: "you found my guestbook! please take a moment to sign it (:".into(),
|
||||
placeholder_body: "Kilroy was here Ω".into(),
|
||||
canonical: None,
|
||||
palette: ConfigPalette::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConfigPalette {
|
||||
pub bg_main: String,
|
||||
pub bg_off: String,
|
||||
pub fg_main: String,
|
||||
pub fg_off: String,
|
||||
pub accent: String,
|
||||
pub accent_dark: String,
|
||||
pub accent_light: String,
|
||||
}
|
||||
|
||||
impl Default for ConfigPalette {
|
||||
fn default() -> Self {
|
||||
ConfigPalette {
|
||||
bg_main: "#201F29".into(),
|
||||
bg_off: "#292835".into(),
|
||||
fg_main: "#E8E1D3".into(),
|
||||
fg_off: "#ADA9A1".into(),
|
||||
accent: "#BF616A".into(),
|
||||
accent_dark: "#824E53".into(),
|
||||
accent_light: "#D1888E".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConfigNotifiers {
|
||||
pub providers: Vec<NotifierProvider>,
|
||||
|
|
41
src/main.rs
41
src/main.rs
|
@ -1,4 +1,3 @@
|
|||
use std::{net::SocketAddr, io::Write};
|
||||
use clap::{Parser, Subcommand};
|
||||
use config::ConfigOverrides;
|
||||
|
||||
|
@ -11,6 +10,9 @@ mod model;
|
|||
mod storage;
|
||||
mod config;
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
mod web;
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(author, version, about)]
|
||||
|
@ -62,15 +64,11 @@ async fn main() {
|
|||
.init();
|
||||
|
||||
match args.action {
|
||||
CliAction::Default => {
|
||||
let mut cfg = Config::default();
|
||||
cfg.notifiers.providers.push(NotifierProvider::Console);
|
||||
#[cfg(feature = "telegram")]
|
||||
cfg.notifiers.providers.push(NotifierProvider::Telegram { token: "asd".into(), chat_id: -1 });
|
||||
println!("{}", toml::to_string(&cfg).unwrap());
|
||||
},
|
||||
CliAction::Default => println!("{}", toml::to_string(&Config::default()).unwrap()),
|
||||
CliAction::Review { batch } => {
|
||||
use std::io::Write;
|
||||
sqlx::any::install_default_drivers(); // must install all available drivers before connecting
|
||||
if_using_sqlite_driver_and_file_is_missing_create_it_beforehand(&args.db);
|
||||
let storage = StorageProvider::connect(&args.db, ConfigOverrides::default()).await.unwrap();
|
||||
let mut offset = 0;
|
||||
let mut buffer = String::new();
|
||||
|
@ -95,8 +93,6 @@ async fn main() {
|
|||
println!("* done");
|
||||
},
|
||||
CliAction::Serve { addr, config } => {
|
||||
let addr : SocketAddr = addr.parse().expect("invalid host provided");
|
||||
|
||||
let config = match config {
|
||||
None => Config::default(),
|
||||
Some(path) => {
|
||||
|
@ -106,9 +102,10 @@ async fn main() {
|
|||
};
|
||||
|
||||
sqlx::any::install_default_drivers(); // must install all available drivers before connecting
|
||||
if_using_sqlite_driver_and_file_is_missing_create_it_beforehand(&args.db);
|
||||
let storage = StorageProvider::connect(&args.db, config.overrides).await.unwrap();
|
||||
|
||||
let mut state = Context::new(storage);
|
||||
let mut state = Context::new(storage, #[cfg(feature = "web")] config.template);
|
||||
|
||||
for notifier in config.notifiers.providers {
|
||||
match notifier {
|
||||
|
@ -142,12 +139,28 @@ async fn main() {
|
|||
|
||||
let router = routes::create_router_with_app_routes(state);
|
||||
|
||||
tracing::info!("listening on {}", addr);
|
||||
tracing::info!("serving on http://{}/", addr);
|
||||
|
||||
axum::Server::bind(&addr)
|
||||
.serve(router.into_make_service())
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
|
||||
axum::serve(listener, router)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// it drives me nuts that it doesn't do it by default!!! is there an option?
|
||||
fn if_using_sqlite_driver_and_file_is_missing_create_it_beforehand(uri: &str) {
|
||||
use std::str::FromStr;
|
||||
if !uri.starts_with("sqlite://") { return };
|
||||
let path = uri.replace("sqlite://", "");
|
||||
if let Ok(p) = std::path::PathBuf::from_str(&path) {
|
||||
if !p.is_file() {
|
||||
if let Err(e) = std::fs::File::create(p) {
|
||||
tracing::warn!("could not create sqlite database file at {} : {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,25 +1,55 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, Form, Router, routing::{put, post, get}, extract::{State, Query}, response::Redirect};
|
||||
use axum::{Json, Form, Router, routing::{put, post, get}, extract::{State, Query}, response::{Redirect, Html}};
|
||||
use axum_extra::response::{Css, JavaScript};
|
||||
|
||||
use crate::{notifications::NotificationProcessor, model::{Page, PageOptions, PageInsertion, PageView}, storage::StorageProvider};
|
||||
use crate::{notifications::NotificationProcessor, model::{Page, PageOptions, PageInsertion, PageView}, storage::StorageProvider, web::IndexTemplate};
|
||||
|
||||
pub fn create_router_with_app_routes(state: Context) -> Router {
|
||||
Router::new()
|
||||
let mut router = Router::new()
|
||||
.route("/api", get(get_suggestion))
|
||||
.route("/api", post(send_suggestion_form))
|
||||
.route("/api", put(send_suggestion_json))
|
||||
.with_state(Arc::new(state))
|
||||
.route("/api", put(send_suggestion_json));
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
use sailfish::TemplateOnce;
|
||||
let template = state.template.clone();
|
||||
router = router
|
||||
.route("/style.css", get(|| async { Css(crate::web::STATIC_CSS) }))
|
||||
.route("/infiniscroll.js", get(|| async { JavaScript(crate::web::STATIC_JS) }))
|
||||
.route("/", get(|| async move {
|
||||
match IndexTemplate::from(&template).render_once() {
|
||||
Ok(txt) => Ok(Html(txt)),
|
||||
Err(e) => Err((
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("could not render template: {}", e)
|
||||
)),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
router.with_state(Arc::new(state))
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
providers: Vec<Box<dyn NotificationProcessor<Page>>>,
|
||||
storage: StorageProvider,
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
template: crate::config::ConfigTemplate,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(storage: StorageProvider) -> Self {
|
||||
Context { providers: Vec::new(), storage }
|
||||
pub fn new(
|
||||
storage: StorageProvider,
|
||||
#[cfg(feature = "web")] template: crate::config::ConfigTemplate,
|
||||
) -> Self {
|
||||
Context {
|
||||
providers: Vec::new(),
|
||||
storage,
|
||||
#[cfg(feature = "web")] template,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, notifier: Box<dyn NotificationProcessor<Page>>) {
|
||||
|
|
|
@ -86,10 +86,10 @@ impl StorageProvider {
|
|||
)
|
||||
}
|
||||
|
||||
pub async fn extract(&self, offset: i32, window: i32, public: bool) -> sqlx::Result<Vec<PageView>> {
|
||||
pub async fn extract(&self, offset: i32, limit: i32, public: bool) -> sqlx::Result<Vec<PageView>> {
|
||||
let out = sqlx::query_as::<_, Page>("SELECT * FROM pages WHERE public = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3")
|
||||
.bind(if public { 1 } else { 0 }) // TODO since AnyPool won't handle booleans we compare with an integer
|
||||
.bind(window)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.db)
|
||||
.await?
|
||||
|
|
18
src/web.rs
Normal file
18
src/web.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
use sailfish::TemplateOnce;
|
||||
|
||||
use crate::config::ConfigTemplate;
|
||||
|
||||
pub const STATIC_CSS : &str = include_str!("../web/style.css");
|
||||
pub const STATIC_JS : &str = include_str!("../web/infiniscroll.js");
|
||||
|
||||
#[derive(Debug, TemplateOnce)]
|
||||
#[template(path = "index.stpl")]
|
||||
pub struct IndexTemplate<'a> {
|
||||
root: &'a ConfigTemplate,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a ConfigTemplate> for IndexTemplate<'a> {
|
||||
fn from(value: &'a ConfigTemplate) -> Self {
|
||||
IndexTemplate { root: value }
|
||||
}
|
||||
}
|
89
web/index.stpl
Normal file
89
web/index.stpl
Normal file
|
@ -0,0 +1,89 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= root.title %></title>
|
||||
<% if let Some(href) = &root.canonical { %><link rel="canonical" href="<%= href %>"><% } %>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="<%= root.title %>">
|
||||
<meta property="og:description" content="<%= root.description %>">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: <%= root.palette.bg_main %>;
|
||||
--bg-secondary: <%= root.palette.bg_off %>;
|
||||
--fg-primary: <%= root.palette.fg_main %>;
|
||||
--fg-secondary: <%= root.palette.fg_off %>;
|
||||
--accent: <%= root.palette.accent %>;
|
||||
--accent-dark: <%= root.palette.accent_dark %>;
|
||||
--accent-light: <%= root.palette.accent_light %>;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center">
|
||||
<h1><%= root.title %></h1>
|
||||
<img src="<%= root.logo %>" class="round-cover" title="guestbook cover">
|
||||
<p><%= root.description %></p>
|
||||
<div>
|
||||
<form action="/api" method="post">
|
||||
<div class="full-width">
|
||||
<!-- TODO can i get this textarea to be full width without weird magic percentages?? -->
|
||||
<textarea class="input-fields" name="body" placeholder="<%= root.placeholder_body %>" title="main message body, html will be escaped" required></textarea>
|
||||
</div>
|
||||
<div class="full-width">
|
||||
<input class="input-fields" type="text" name="author" maxlength="25" placeholder="your name" title="name to show next to your comment" required>
|
||||
<input class="input-fields" type="text" name="contact" maxlength="50" title="email or website, optional, used for avatar (libravatar.org)" placeholder="contact (optional)">
|
||||
<input class="input-button" type="submit" value="send!">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<table id="#container" class="center" style="margin-top: 3em" cellspacing="0">
|
||||
<tr>
|
||||
<td style="width: 20%"></td>
|
||||
<td style="width: 80%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><hr></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="tiny" style="margin-top: 5em"><b>guestbook.rs</b> ~ made with <3 by <a href="mailto:me@alemi.dev">alemi</a></p>
|
||||
</div>
|
||||
<script type="module" src="/infiniscroll.js"></script>
|
||||
<script type="module" language="javascript">
|
||||
import hookInfiniteScroll from "./infiniscroll.js";
|
||||
|
||||
hookInfiniteScroll((repl) => {
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
${repl.url ? '<a href="' + repl.url + '">' : ''}
|
||||
<img class="avatar" src="https://seccdn.libravatar.org/avatar/${repl.avatar}?s=150&default=identicon">
|
||||
${repl.url ? '</a>' : '' }
|
||||
</td>
|
||||
<td rowspan="2">
|
||||
<p style="text-align: left">${repl.body}</p>
|
||||
<p class="tiny" style="text-align: right">${(new Date(repl.date)).toLocaleString()}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<details>
|
||||
<summary>
|
||||
${repl.contact ? '<b class="contact">' : ''}
|
||||
<span class="author">${repl.author}</span>
|
||||
${repl.contact ? '</b>' : ''}
|
||||
</summary>
|
||||
<i class="tiny">${repl.contact ? repl.contact : ''}</i>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><hr></td>
|
||||
</tr>`
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
72
web/infiniscroll.js
Normal file
72
web/infiniscroll.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* @typedef GuestbookPage
|
||||
* @type {Object}
|
||||
* @property {number} id - unique page id
|
||||
* @property {string} author - name of page author
|
||||
* @property {string} body - main page body
|
||||
* @property {string|undefined} contact - optional page author's contact
|
||||
* @property {string|undefined} url - optional url to author's contact, if either mail or http link
|
||||
* @property {string} avatar - unique hash (based on contact) to use for libravatar img href
|
||||
* @property {Date} date - time of creation for page
|
||||
* @property
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook infinite scroll callback with element builder
|
||||
*
|
||||
* @param {(data:GuestbookPage)=>string} builder - function invoked to generate new elements, must return HTML string
|
||||
* @param {Object} [opt] - configuration options for infinite scroll behavior
|
||||
* @param {number} [opt.threshold] - from 0 to 1, percentage of scroll at which new fetch triggers (default 0.9)
|
||||
* @param {number} [opt.debounce] - how many milliseconds to wait before processing new scroll events (default 250)
|
||||
* @param {string} [opt.container_id] - html id of container element to inject new values into (default '#container')
|
||||
* @param {string} [opt.api_url] - url for api calls (default '/api')
|
||||
* @param {string} [opt.offset_arg] - how the offset/page argument is called in the api backend (default 'offset')
|
||||
* @param {boolean} [opt.prefetch] - load first page immediately, before any scroll event is received (default true)
|
||||
* @param {boolean} [opt.free_index] - wether backend api allows free indexing (?offset=17) or expects pagination (?page=1) (default true)
|
||||
*
|
||||
* @example
|
||||
* hookInfiniteScroll(
|
||||
* (data) => { return `<li><b>${data.author}</b>: ${data.body}</li>` },
|
||||
* {threshold: 0.75, debounce: 1000, api_url: 'http://my.custom.backend/api'}
|
||||
* );
|
||||
*/
|
||||
export default function hookInfiniteScroll(builder, opt) {
|
||||
if (opt === undefined) opt = {};
|
||||
// we could do it with less lines using || but it checks for truthy (e.g. would discard debounce 0)
|
||||
// explicitly check for undefined to avoid this, it's done only once at the start anyway
|
||||
let container_id = opt.container_id != undefined ? opt.container_id : "#container";
|
||||
let threshold = opt.threshold != undefined ? opt.threshold : 0.9;
|
||||
let debounce = opt.debounce != undefined ? opt.debounce : 250;
|
||||
let api_url = opt.api_url != undefined ? opt.api_url : "/api";
|
||||
let prefetch = opt.prefetch != undefined ? opt.prefetch : true;
|
||||
let offset_arg = opt.offset_arg != undefined ? opt.offset_arg : "offset";
|
||||
let free_index = opt.free_index != undefined ? opt.free_index : true;
|
||||
|
||||
let container = document.getElementById(container_id);
|
||||
let last_activation = Date.now();
|
||||
let offset = 0;
|
||||
|
||||
function scrollDepth() {
|
||||
let html_tag = document.body.parentElement;
|
||||
return html_tag.scrollTop / (html_tag.scrollHeight - html_tag.clientHeight)
|
||||
}
|
||||
|
||||
let callback = async (ev) => {
|
||||
if (ev !== true && scrollDepth() < threshold) return; // not scrolled enough
|
||||
if (ev !== true && Date.now() - last_activation < debounce) return; // triggering too fast
|
||||
last_activation = Date.now();
|
||||
let response = await fetch(`${api_url}?${offset_arg}=${offset}`);
|
||||
let replies = await response.json();
|
||||
if (replies.length == 0) {
|
||||
// reached end, unregister self
|
||||
return document.removeEventListener("scroll", callback);
|
||||
}
|
||||
offset += free_index ? replies.length : 1; // track how deep we went
|
||||
for (let repl of replies) {
|
||||
container.innerHTML += builder(repl);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("scroll", callback);
|
||||
if (prefetch) callback(true); // invoke once immediately
|
||||
}
|
160
web/style.css
Normal file
160
web/style.css
Normal file
|
@ -0,0 +1,160 @@
|
|||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--fg-primary);
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: var(--accent-light);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
width: 60%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
table.center {
|
||||
padding-left: 5em;
|
||||
padding-right: 5em;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tiny {
|
||||
font-size: .5em;
|
||||
}
|
||||
|
||||
img.avatar {
|
||||
border-radius: 50%;
|
||||
padding: .25em;
|
||||
width: 2.25em;
|
||||
height: 2.25em;
|
||||
border: solid .125em var(--accent);
|
||||
}
|
||||
|
||||
a img.avatar:hover {
|
||||
padding: .125em;
|
||||
border: solid .25em var(--accent-light);
|
||||
}
|
||||
|
||||
span.author {
|
||||
color: var(--fg-secondary)
|
||||
}
|
||||
|
||||
b.contact {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img.round-cover {
|
||||
width: 45%;
|
||||
border: solid 20px var(--accent);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--accent);
|
||||
padding: .5em;
|
||||
margin: .5em;
|
||||
color: var(--fg-primary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
textarea.input-fields {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
input.input-fields {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
input.input-button {
|
||||
width: 6%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1600px) {
|
||||
img.pixar {
|
||||
width: 60%;
|
||||
border: solid 15px var(--accent);
|
||||
}
|
||||
table.center {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
textarea.input-fields {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
input.input-fields {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
input.input-button {
|
||||
width: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.center {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
img.pixar {
|
||||
border: solid 10px var(--accent);
|
||||
padding: 15px;
|
||||
}
|
||||
.center {
|
||||
width: 100%;
|
||||
}
|
||||
textarea.input-fields {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
input.input-fields {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
input.input-button {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in a new issue