mirror of
https://gitlab.aachen.ccc.de/inventory/in.git
synced 2024-11-29 10:13:59 +01:00
initial commit
This commit is contained in:
commit
0961d0e47d
15 changed files with 564 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/.dockerignore
|
||||||
|
/.gitignore
|
||||||
|
/inventory.sqlite3
|
||||||
|
/README.md
|
||||||
|
*.pyc
|
||||||
|
Dockerfile
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/inventory.sqlite3
|
||||||
|
*.pyc
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
FROM python:3.10-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN python -m pip install -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENTRYPOINT [ "uvicorn", "main:app", "--use-colors", "--host", "0.0.0.0" ]
|
||||||
|
EXPOSE 8000
|
25
README.md
Normal file
25
README.md
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# in?
|
||||||
|
|
||||||
|
A simple inventory system.
|
||||||
|
|
||||||
|
It uses `fastapi`, `sqlalchemy` and `sqlite`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t in .
|
||||||
|
docker run -p 127.0.0.1:8000:8000 -it in
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit http://127.0.0.1:8000/.
|
||||||
|
|
||||||
|
The database lives at `inventory.sqlite3` (at `/app/` inside the container).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
API documentation is generated at http://127.0.0.1:8000/docs or http://127.0.0.1:8000/redoc.
|
20
crud.py
Normal file
20
crud.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import DECIMAL, cast
|
||||||
|
|
||||||
|
import models, schemas
|
||||||
|
|
||||||
|
def get_items(db: Session) -> list[models.Item]:
|
||||||
|
return db.query(models.Item).order_by(models.Item.type, cast(models.Item.id, DECIMAL))
|
||||||
|
|
||||||
|
|
||||||
|
def put_item(db: Session, id: str, item: schemas.Item):
|
||||||
|
updated = bool(db.query(models.Item).filter(models.Item.id == id).update(item.dict()))
|
||||||
|
if not updated:
|
||||||
|
db.add(models.Item(**item.dict()))
|
||||||
|
db.commit()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(db: Session, id: str):
|
||||||
|
db.query(models.Item).filter(models.Item.id == id).delete()
|
||||||
|
db.commit()
|
14
database.py
Normal file
14
database.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./inventory.sqlite3"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL,
|
||||||
|
connect_args = {"check_same_thread": False},
|
||||||
|
echo=True
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
47
main.py
Normal file
47
main.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
from fastapi import Depends, FastAPI, Response
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
import crud
|
||||||
|
from database import SessionLocal, engine
|
||||||
|
from models import Base
|
||||||
|
from schemas import Item
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@event.listens_for(Engine, "connect")
|
||||||
|
def _set_sqlite_pragma(conn, _):
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON;")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/items", response_model=dict[str, Item])
|
||||||
|
async def list_items(db: Session = Depends(get_db)):
|
||||||
|
items = crud.get_items(db)
|
||||||
|
return {i.id:i for i in items}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/items/{id}")
|
||||||
|
async def put_item(id: str, item: Item, db: Session = Depends(get_db)):
|
||||||
|
if crud.put_item(db, id, item):
|
||||||
|
return Response(b'', status_code=204)
|
||||||
|
return Response(b'', status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/items/{id}", status_code=204)
|
||||||
|
async def delete_item(id: str, db: Session = Depends(get_db)):
|
||||||
|
crud.delete_item(db, id)
|
||||||
|
|
||||||
|
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
16
models.py
Normal file
16
models.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from sqlalchemy import Column, ForeignKey, String, Text, Boolean
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
class Item(Base):
|
||||||
|
__tablename__ = "items"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
is_in = Column(String, ForeignKey(id, onupdate="CASCADE", ondelete="SET NULL"))
|
||||||
|
coords_bl = Column(String)
|
||||||
|
coords_tr = Column(String)
|
||||||
|
type = Column(String)
|
||||||
|
name = Column(String)
|
||||||
|
content = Column(Text)
|
||||||
|
note = Column(Text)
|
||||||
|
hidden = Column(Boolean, nullable=False)
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
fastapi>=0.79.0
|
||||||
|
sqlalchemy>=1.4.39
|
||||||
|
uvicorn>=0.17.6
|
16
schemas.py
Normal file
16
schemas.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Item(BaseModel):
|
||||||
|
id: str
|
||||||
|
is_in: str | None
|
||||||
|
coords_bl: str | None
|
||||||
|
coords_tr: str | None
|
||||||
|
type: str | None
|
||||||
|
name: str | None
|
||||||
|
content: str | None
|
||||||
|
note: str | None
|
||||||
|
hidden: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
44
static/form.html
Normal file
44
static/form.html
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>in?</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script src="form.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1><a href="../">in?</a></h1>
|
||||||
|
<form id="form">
|
||||||
|
<label for="id">ID</label>
|
||||||
|
<input id="id" type="text" placeholder="loading...">
|
||||||
|
|
||||||
|
<label for="is_in">Is in</label>
|
||||||
|
<select id="is_in">
|
||||||
|
<option value=""></option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="coords_bl">Bottom left coordinates</label>
|
||||||
|
<input id="coords_bl" type="text">
|
||||||
|
|
||||||
|
<label for="coords_tr">Top right coordinates</label>
|
||||||
|
<input id="coords_tr" type="text">
|
||||||
|
|
||||||
|
<label for="type">Type</label>
|
||||||
|
<input id="type" type="text">
|
||||||
|
|
||||||
|
<label for="name">Name / Label</label>
|
||||||
|
<input id="name" type="text">
|
||||||
|
|
||||||
|
<label for="content">Content</label>
|
||||||
|
<textarea id="content"></textarea>
|
||||||
|
|
||||||
|
<label for="note">Note</label>
|
||||||
|
<textarea id="note"></textarea>
|
||||||
|
|
||||||
|
<label for="hidden">Hide by default</label>
|
||||||
|
<input type="checkbox" id="hidden">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button id="delete" class="btn red" autocomplete="off">Delete</button>
|
||||||
|
<button id="save" class="btn green" autocomplete="off">Save</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
103
static/form.js
Normal file
103
static/form.js
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
let items = {};
|
||||||
|
const id = new URLSearchParams(window.location.search).get('id');
|
||||||
|
|
||||||
|
document.onreadystatechange = function() {
|
||||||
|
if (document.readyState === 'interactive') {
|
||||||
|
fetch('/api/items').then(r => r.json()).then((data) => {
|
||||||
|
items = data;
|
||||||
|
fillForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm() {
|
||||||
|
const item = items[id];
|
||||||
|
|
||||||
|
const is_in = document.getElementById('is_in');
|
||||||
|
|
||||||
|
for (const [_, item] of Object.entries(items)) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = `${item.id} ${item.name && `(${item.name})` || ''}`
|
||||||
|
is_in.appendChild(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('id').placeholder = '';
|
||||||
|
|
||||||
|
if (id && item) {
|
||||||
|
document.getElementById('id').value = item.id;
|
||||||
|
document.getElementById('is_in').value = item.is_in;
|
||||||
|
document.getElementById('coords_bl').value = item.coords_bl;
|
||||||
|
document.getElementById('coords_tr').value = item.coords_tr;
|
||||||
|
document.getElementById('type').value = item.type;
|
||||||
|
document.getElementById('name').value = item.name;
|
||||||
|
document.getElementById('content').value = item.content;
|
||||||
|
document.getElementById('note').value = item.note;
|
||||||
|
document.getElementById('hidden').checked = item.hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('save');
|
||||||
|
const delBtn = document.getElementById('delete');
|
||||||
|
|
||||||
|
saveBtn.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
|
delBtn.onclick = function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
del();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
item = {
|
||||||
|
id: document.getElementById('id').value || null,
|
||||||
|
is_in: document.getElementById('is_in').value || null,
|
||||||
|
coords_bl: document.getElementById('coords_bl').value || null,
|
||||||
|
coords_tr: document.getElementById('coords_tr').value || null,
|
||||||
|
type: document.getElementById('type').value || null,
|
||||||
|
name: document.getElementById('name').value || null,
|
||||||
|
content: document.getElementById('content').value || null,
|
||||||
|
note: document.getElementById('note').value || null,
|
||||||
|
hidden: document.getElementById('hidden').checked,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items[item.id]) {
|
||||||
|
if (!id || (id && id != item.id)) {
|
||||||
|
alert(`ID ${item.id} exists. Please chose a different ID or edit the existing ${item.id} item!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const original_id = id || item.id;
|
||||||
|
|
||||||
|
fetch(`/api/items/${original_id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(item),
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.href = '../';
|
||||||
|
} else {
|
||||||
|
r.text().then(t => alert(`Error:\n${t}`));
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
alert(`Error:\n${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function del() {
|
||||||
|
if (id && confirm('Are you sure?')) {
|
||||||
|
fetch(`/api/items/${id}`, {method: 'DELETE'}).then(r => {
|
||||||
|
if (r.ok) {
|
||||||
|
window.location.href = '../';
|
||||||
|
} else {
|
||||||
|
r.text().then(t => alert(`Error:\n${t}`));
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
alert(`Error:\n${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
30
static/index.html
Normal file
30
static/index.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>in?</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script src="index.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>in?</h1>
|
||||||
|
<input type="text" id="search" value="" autocomplete="off" placeholder="RegEx search" tabindex="1" autofocus>
|
||||||
|
<label>Show hidden items</label> <input type="checkbox" id="showhidden">
|
||||||
|
<div id="results">
|
||||||
|
<span id="loading">loading…</span>
|
||||||
|
<template id="item">
|
||||||
|
<div id="" class="result" tabindex="0">
|
||||||
|
<h2>
|
||||||
|
<span class="id"></span>
|
||||||
|
<span class="name"></span>
|
||||||
|
<a title="edit">⚙</a>
|
||||||
|
<span class="loc"></span>
|
||||||
|
</h2>
|
||||||
|
<div class="type"></div>
|
||||||
|
<div class="note"></div>
|
||||||
|
<div class="content"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<a href="form.html" class="btn green">+</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
92
static/index.js
Normal file
92
static/index.js
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
let items = {};
|
||||||
|
|
||||||
|
document.onreadystatechange = function() {
|
||||||
|
if (document.readyState === 'interactive') {
|
||||||
|
document.getElementById('search').oninput = search;
|
||||||
|
const checkbox = document.getElementById('showhidden')
|
||||||
|
checkbox.oninput = showhidden;
|
||||||
|
fetch('/api/items').then((response) => response.json()).then((data) => {
|
||||||
|
items = data;
|
||||||
|
renderItems();
|
||||||
|
showhidden({target: checkbox});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems() {
|
||||||
|
const container = document.getElementById('results');
|
||||||
|
const template = document.getElementById('item');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
for (const [id, item] of Object.entries(items)) {
|
||||||
|
const clone = template.content.cloneNode(true);
|
||||||
|
const [loc, longloc] = getLocString(items, item);
|
||||||
|
clone.querySelector(".loc").textContent = loc;
|
||||||
|
clone.querySelector(".loc").title = longloc;
|
||||||
|
clone.querySelector(".type").textContent = item.type;
|
||||||
|
clone.querySelector(".id").textContent = item.id;
|
||||||
|
clone.querySelector(".name").textContent = item.name;
|
||||||
|
clone.querySelector(".content").textContent = item.content;
|
||||||
|
clone.querySelector(".note").textContent = item.note;
|
||||||
|
clone.querySelector(".result").id = id;
|
||||||
|
clone.querySelector("a").href = `form.html?id=${id}`;
|
||||||
|
if (item.hidden) {
|
||||||
|
clone.querySelector(".result").classList.add('hidden')
|
||||||
|
};
|
||||||
|
container.appendChild(clone);
|
||||||
|
};
|
||||||
|
loading.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocString(items, item) {
|
||||||
|
if (item.is_in == item.id) {
|
||||||
|
return ['in ↻', `${item.id} is in itself`];
|
||||||
|
}
|
||||||
|
let ancestors = [];
|
||||||
|
let next = item;
|
||||||
|
while (next) {
|
||||||
|
ancestors.unshift(next);
|
||||||
|
next = items[next.is_in];
|
||||||
|
}
|
||||||
|
ancestors.pop();
|
||||||
|
const loc = ancestors.map(i => i.id).join(' ➜ ') || '⬚';
|
||||||
|
let longloc = ancestors.map(i => `${i.type || ''} ${i.id} ${i.name && `(${i.name})` || ''}`).join(' ➜ ') || 'universe';
|
||||||
|
return [loc, `${item.id} is in ${longloc}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(e) {
|
||||||
|
const searchAttrs = ['id', 'name', 'type', 'note', 'content'];
|
||||||
|
const query = e.target.value;
|
||||||
|
const regex = new RegExp(query, 'i')
|
||||||
|
|
||||||
|
for (const elem of document.getElementsByClassName('result')) {
|
||||||
|
const item = items[elem.id];
|
||||||
|
let found = false;
|
||||||
|
if (query) {
|
||||||
|
for (const a in searchAttrs) {
|
||||||
|
const attr = item[searchAttrs[a]];
|
||||||
|
if (attr && attr.match(regex)) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
elem.classList.remove('filtered');
|
||||||
|
} else {
|
||||||
|
elem.classList.add('filtered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showhidden(e){
|
||||||
|
const container = document.getElementById('results');
|
||||||
|
if (e.target.checked) {
|
||||||
|
results.classList.add('showhidden');
|
||||||
|
} else {
|
||||||
|
results.classList.remove('showhidden');
|
||||||
|
}
|
||||||
|
}
|
136
static/style.css
Normal file
136
static/style.css
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: auto;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
background: #fefefe;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, #search {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
background: #eee;
|
||||||
|
padding: 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #03d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results.showhidden .result.hidden {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.filtered {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc {
|
||||||
|
float: right;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
background: #aef;
|
||||||
|
padding: 1em;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid;
|
||||||
|
padding: 0.5em;
|
||||||
|
text-decoration: initial;
|
||||||
|
color: initial;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.green {
|
||||||
|
background: #0a0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.red {
|
||||||
|
background: #a00;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
form label {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
padding: 0.3em;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.4em;
|
||||||
|
background: initial;
|
||||||
|
border: 1px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=checkbox] {
|
||||||
|
width: initial;
|
||||||
|
display: initial;
|
||||||
|
min-width: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 8em;
|
||||||
|
}
|
Loading…
Reference in a new issue