initial import

This commit is contained in:
slynn1324 2021-01-21 14:10:32 -06:00
commit dc3206c00b
13 changed files with 3791 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
images
node_modules
data.db
.DS_Store

332
app.js Normal file
View file

@ -0,0 +1,332 @@
const express = require('express');
const bodyParser = require('body-parser');
const db = require('better-sqlite3')('data.db') //, {verbose:console.log});
const http = require('http');
const https = require('https');
const sharp = require('sharp');
const fs = require('fs').promises;
const fetch = require('node-fetch');
const IMAGE_PATH = "./images";
// express config
const app = express();
const port = 3000;
app.use(express.static('static'));
app.use(express.static('images'));
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json());
app.set('json spaces', 2);
const OK = {status: "ok"};
const NOT_FOUND = {status: "error", error: "not found"};
const ALREADY_EXISTS = {status: "error", error: "already exists"};
const SERVER_ERROR = {status: "error", error: "server error"};
initDb();
// list boards
app.get("/api/boards", (req, res) => {
try{
let boards = db.prepare("SELECT * FROM boards").all();
for( let i = 0; i < boards.length; ++i ){
let result = db.prepare("SELECT id FROM pins WHERE boardId = ? order by createDate limit 1").get(boards[i].id);
if ( result ) {
boards[i].titlePinId = result.id;
} else {
boards[i].titlePinId = 0;
}
}
res.send(boards);
} catch (err) {
console.log(`Error listing boards: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// get board
app.get("/api/boards/:boardId", (req, res) => {
try{
let board = db.prepare("SELECT * FROM boards WHERE id = ?").get(req.params.boardId);
if ( board ){
board.pins = db.prepare("SELECT * FROM pins WHERE boardId = ?").all(req.params.boardId);
res.send(board);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error getting board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// create board
app.post('/api/boards', (req, res) => {
try{
let result = db.prepare("INSERT INTO boards (name, createDate) VALUES (@name, @createDate)").run({name: req.body.name, createDate: new Date().toISOString()});
let id = result.lastInsertRowid;
let board = db.prepare("SELECT * FROM boards WHERE id = ?").get(id);
res.send(board);
console.log(`Created board#${id} ${req.body.name}`);
} catch (err){
console.log("Error creating board: " + err.message);
if ( err.message.includes('UNIQUE constraint failed:') ){
res.status(409).send(ALREADY_EXISTS);
} else {
res.status(500).send(SERVER_ERROR);
}
}
});
// update board
app.post("/api/boards/:boardId", (req, res) =>{
try{
let result = db.prepare("UPDATE boards SET name = @name WHERE id = @boardId").run({name: req.body.name, boardId: req.params.boardId});
if ( result.changes == 1 ){
res.send(OK);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.log(`Error updating board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// delete board
app.delete("/api/boards/:boardId", (req, res) => {
try{
let result = db.prepare("DELETE FROM boards WHERE id = ?").run(req.params.boardId);
if ( result.changes == 1 ){
res.send(OK);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error deleting board#${req.params.boardId}: ${err.message}`);
res.status(500).send(SERVER_ERROR);
}
});
// get pin
app.get("/api/pins/:pinId", (req, res) => {
try {
let pin = db.prepare('SELECT * FROM pins WHERE id = ?').get(req.params.pinId);
if ( pin ){
res.send(pin);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err){
console.error(`Error getting pin#${req.params.pinId}: ${err.message}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// create pin
app.post("/api/pins", async (req, res) => {
try {
console.log(req.body);
let image = await downloadImage(req.body.imageUrl);
console.log(image);
let result = db.prepare(`INSERT INTO PINS (
boardId,
imageUrl,
siteUrl,
description,
sortOrder,
originalHeight,
originalWidth,
thumbnailHeight,
thumbnailWidth,
createDate
) VALUES (
@boardId,
@imageUrl,
@siteUrl,
@description,
@sortOrder,
@originalHeight,
@originalWidth,
@thumbnailHeight,
@thumbnailWidth,
@createDate)
`).run({
boardId: req.body.boardId,
imageUrl: req.body.imageUrl,
siteUrl: req.body.siteUrl,
description: req.body.description,
sortOrder: req.body.sortOrder,
originalHeight: image.original.height,
originalWidth: image.original.width,
thumbnailHeight: image.thumbnail.height,
thumbnailWidth: image.thumbnail.width,
createDate: new Date().toISOString()
});
let id = result.lastInsertRowid;
// write the images to disk
let originalImagePath = getOriginalImagePath(id);
let thumbnailImagePath = getThumbnailImagePath(id);
await fs.mkdir(originalImagePath.dir, {recursive: true});
await fs.mkdir(thumbnailImagePath.dir, {recursive: true});
await fs.writeFile(originalImagePath.file, image.original.buffer);
console.log(`Saved original to: ${originalImagePath.file}`);
await fs.writeFile(thumbnailImagePath.file, image.thumbnail.buffer);
console.log(`Saved thumbnail to: ${thumbnailImagePath.file}`);
// return the newly created row
let pin = db.prepare("SELECT * FROM pins WHERE id = ?").get(id);
res.send(pin);
} catch (err) {
console.log(`Error creating pin: ${err.message}`, err);
res.status(500).send(SERVER_ERROR);
}
});
app.post("/api/pins/:pinId", (req,res) => {
try {
let result = db.prepare(`UPDATE pins SET
boardId = @boardId,
siteUrl = @siteUrl,
description = @description,
sortOrder = @sortOrder
WHERE id = @pinId
`).run({
pinId: req.params.pinId,
boardId: req.body.boardId,
siteUrl: req.body.siteUrl,
description: req.body.description,
sortOrder: req.body.sortOrder
});
if ( result.changes == 1 ){
res.send(OK);
} else {
res.status(404).send(NOT_FOUND);
}
} catch (err) {
console.log(`Error updating pin#${req.params.pinId}`, err);
res.status(500).send(SERVER_ERROR);
}
});
// start listening
app.listen(port, () => {
console.log(`tinypin is running at http://localhost:${port}`)
});
function initDb(){
db.prepare(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
createDate TEXT
)
`).run();
let schemaVersion = db.prepare('select max(id) as id from migrations').get().id;
if ( !schemaVersion || schemaVersion < 1 ){
console.log("Running migration to version 1");
db.prepare(`
CREATE TABLE IF NOT EXISTS boards (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
createDate TEXT)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS pins (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
boardId INTEGER NOT NULL,
imageUrl TEXT,
siteUrl TEXT,
description TEXT,
sortOrder INTEGER,
originalHeight INTEGER,
originalWidth INTEGER,
thumbnailHeight INTEGER,
thumbnailWidth INTEGER,
createDate TEXT,
FOREIGN KEY (boardId) REFERENCES boards(id)
)
`).run();
db.prepare("INSERT INTO migrations (id, createDate) VALUES ( @id, @createDate )").run({id:1, createDate: new Date().toISOString()});
} else {
console.log("Database schema v" + schemaVersion + " is up to date.");
}
}
async function downloadImage(imageUrl){
let res = await fetch(imageUrl);
if ( res.status != 200 ){
throw(new Error(`download error status=${res.status}`));
}
let buffer = await res.buffer();
let original = sharp(buffer);
let originalMetadata = await original.metadata();
let originalBuffer = await original.toFormat("jpg").toBuffer();
let thumbnail = await original.resize({ width: 400, height: 400, fit: 'inside' });
let thumbnailBuffer = await thumbnail.toBuffer();
let thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
return {
original: {
buffer: originalBuffer,
width: originalMetadata.width,
height: originalMetadata.height
},
thumbnail: {
buffer: thumbnailBuffer,
width: thumbnailMetadata.width,
height: thumbnailMetadata.height
}
}
}
// function padId(id){
// let result = id.toString();
// while ( result.length < 12 ) {
// result = '0' + result;
// }
// return result;
// }
function getOriginalImagePath(pinId){
let paddedId = pinId.toString().padStart(12, '0');
let dir = `${IMAGE_PATH}/originals/${paddedId[11]}/${paddedId[10]}/${paddedId[9]}/${paddedId[8]}`;
let file = `${dir}/${paddedId}.jpg`;
return {dir: dir, file: file};
}
function getThumbnailImagePath(pinId){
let paddedId = pinId.toString().padStart(12, '0');
let dir = `${IMAGE_PATH}/thumbnails/${paddedId[11]}/${paddedId[10]}/${paddedId[9]}/${paddedId[8]}`;
let file = `${dir}/${paddedId}.jpg`;
return {dir: dir, file: file};
}

View file

@ -0,0 +1,26 @@
/**
* Returns a handler which will open a new window when activated.
*/
function getClickHandler() {
return function(info, tab) {
var q = "i=" + encodeURIComponent(info.srcUrl) + "&s=" + encodeURIComponent(tab.url);
// The srcUrl property is only available for image elements.
var url = 'http://localhost:3000/popup.html#' + q;
// Create a new window to the info page.
// chrome.windows.create({ url: url, width: 520, height: 660 });
chrome.windows.create({ url: url, width: 500, height: 500, type: 'popup' });
};
};
/**
* Create a context menu which will only show up for images.
*/
chrome.contextMenus.create({
"title" : "Get image info",
"type" : "normal",
"contexts" : ["image"],
"onclick" : getClickHandler()
});

View file

@ -0,0 +1,10 @@
{
"name": "TinyPin Plugin",
"version": "1.0",
"description": "TinyPin context plugin",
"manifest_version": 2,
"background" : { "scripts": ["background.js"] },
"permissions" : [
"contextMenus"
]
}

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta name="google" content="notranslate">
</head>
<body>
<div>
image src: <span id="imageSrc"></span>
</div>
<div>
website url: <span id="siteUrl"></span>
</div>
<script src="popup.js"></script>
</body>
</html>

26
chrome-extension/popup.js Normal file
View file

@ -0,0 +1,26 @@
let hash = window.location.hash;
if ( hash.length > 0 && hash.indexOf(0) == "#" ){
hash = hash.substr(1);
}
let q = parseQueryString(window.location.hash.substr(1));
document.getElementById("imageSrc").innerText = q.i || "";
document.getElementById("siteUrl").innerText = q.s || "";
function parseQueryString(qs){
let obj = {};
let parts = qs.split("&");
for ( let i = 0; i < parts.length; ++i ){
let kv = parts[i].split("=");
if ( kv.length == 2 ){
let key = decodeURIComponent(kv[0]);
let value = decodeURIComponent(kv[1]);
obj[key] = value;
}
}
return obj;
}

2385
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "tinypin",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"run": "node app.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^7.1.2",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"node-fetch": "^2.6.1",
"sharp": "^0.27.0"
}
}

104
static/client.css Normal file
View file

@ -0,0 +1,104 @@
.brick-wall {
display: flex;
}
.brick-wall-column {
flex: 1;
width: 100%;
}
.brick {
margin: 0 5px 10px 5px;
}
.brick img {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
background-color: #ccc;
}
#brick-wall-container {
max-width: 95%;
}
.board-brick:hover img{
opacity: 0.9;
}
.board-brick-name {
color: #444;
font-size: 24px;
font-weight: bold;
margin-left: 5px;
}
.board-brick a{
display: block;
height: 250px;
width: 100%;
}
.board-brick a img{
height: 200px;
object-fit: cover;
}
#pin-zoom-modal .modal-content {
height: 90%;
width: 90%;
}
#pin-zoom-modal .modal-content p {
height: 100%;
}
#pin-zoom-modal .modal-content p img {
height: 100%;
width: 100%;
margin-left: auto;
margin-right: auto;
object-fit: contain;
}
#add-pin-modal-board-name {
font-weight: bold;
}
#add-pin-modal .add-pin-flex {
display: flex;
}
#add-pin-modal .add-pin-flex-left {
flex: 1;
margin: 10px;
margin-top: 40px;
}
#add-pin-modal .add-pin-flex-left img{
border-radius: 12px;
width: 100%;
height: auto;
display: block;
}
#add-pin-modal .add-pin-flex-right {
flex: 2;
margin: 10px;
}
#footer {
padding: 1rem 1.5rem 1rem;
}
#app {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.section {
flex: 1;
}

551
static/client.js Normal file
View file

@ -0,0 +1,551 @@
Reef.debug(true);
const store = new Reef.Store({
data: {
boards: [],
board: null,
addPin: {
active: false,
boardId: "",
imageUrl: "",
previewReady: false,
previewImageUrl: null,
siteUrl: "",
description: ""
},
pinZoom: {
active: false,
pinId: null
},
about: {
active: false
}
}
});
const actions = {
openAddPinModal: () => {
if ( store.data.board ){
store.data.addPin.boardId = store.data.board.id;
} else if ( store.data.boards && store.data.boards.length > 0 ){
store.data.addPin.boardId = store.data.boards[0].id;
} else {
store.data.addPin.boardId = 0;
}
store.data.addPin.active = true;
},
closeAddPinModal: () => {
store.data.addPin.active = false;
store.data.addPin.imageUrl = "";
store.data.addPin.previewImageUrl = "";
store.data.addPin.siteUrl = "";
store.data.addPin.description = "";
},
saveAddPin: async () => {
let postData = {
boardId: store.data.addPin.boardId,
imageUrl: store.data.addPin.imageUrl,
siteUrl: store.data.addPin.siteUrl,
description: store.data.addPin.description
};
let res = await fetch('api/pins', {
method: 'POST',
headers: {
'Content-Type': "application/json"
},
body: JSON.stringify(postData)
});
if ( res.status == 200 ){
actions.closeAddPinModal();
let body = await res.json();
store.data.board.pins.push(body);
}
},
updateAddPinPreview: () => {
if ( store.data.addPin.imageUrl.startsWith("http") ){
( async() => {
let res = await fetch(store.data.addPin.imageUrl, {
mode: 'no-cors',
method: "HEAD"
});
if ( res.status = 200 ){
store.data.addPin.previewImageUrl = store.data.addPin.imageUrl;
}
})();
} else {
store.data.addPin.previewImageUrl = null;
}
},
openPinZoomModal: (el) => {
let pinId = el.getAttribute("data-pinid");
if( pinId ){
store.data.pinZoom.pinId = pinId;
store.data.pinZoom.active = true;
}
},
closePinZoomModal: () => {
store.data.pinZoom.active = false;
store.data.pinZoom.pinId = null;
},
movePinZoomModalLeft: () => {
let idx = 0;
for ( let i = 0; i < store.data.board.pins.length; ++i ){
if ( store.data.board.pins[i].id == store.data.pinZoom.pinId ){
idx = i;
}
}
if ( idx > 0 ){
store.data.pinZoom.pinId = store.data.board.pins[idx-1].id;
}
},
movePinZoomModalRight: () => {
let idx = -1;
for ( let i = 0; i < store.data.board.pins.length; ++i ){
if ( store.data.board.pins[i].id == store.data.pinZoom.pinId ){
idx = i;
}
}
if ( idx >= 0 && (idx < store.data.board.pins.length-1) ){
store.data.pinZoom.pinId = store.data.board.pins[idx+1].id
}
},
showAboutModal: () => {
store.data.about.active = true;
},
closeAboutModal: () => {
store.data.about.active = false;
}
}
const app = new Reef("#app", {
store: store,
template: (store) => {
return /*html*/`
<div id="navbar"></div>
<section class="section">
<div class="container" id="brick-wall-container">
<div id="brick-wall" class="brick-wall"></div>
</div>
</section>
<footer class="footer" id="footer">
<div class="content has-text-right">
<p>
<a data-onclick="showAboutModal">about tinypin</a>
</p>
</div>
</footer>
<div id="add-pin-modal"></div>
<div id="pin-zoom-modal"></div>
<div id="about-modal"></div>
`
}
});
const aboutModal = new Reef("#about-modal", {
store: store,
template: (data) => {
return /*html*/`
<div class="modal ${data.about.active ? 'is-active' : ''}">
<div class="modal-background" data-onclick="closeAboutModal"></div>
<div class="modal-content">
<div class="box" style="font-family: monospace;">
<h1><strong>tinypin</strong></h1>
<div>
<a href="https://www.github.com">github.com/slynn1324/tinypin</a>
<br />
&nbsp;
</div>
<div>
<h2><strong>credits</strong></h2>
client
<br />
&nbsp;css framework &raquo; <a href="https://www.bulma.io">bulma.io</a>
<br />
&nbsp;ui framework &raquo; <a href="https://reefjs.com">reef</a>
<br />
&nbsp;icon &raquo; <a href="https://thenounproject.com/term/pinned/1560993/">pinned by Gregor Cresnar from the Noun Project</a>
<br />
server
<br />
&nbsp;language &amp; runtime &raquo; <a href="https://nodejs.org/en/">node.js</a>
<br />
&nbsp;database &raquo; <a href="https://www.sqlite.org/index.html">sqlite</a>
<br />
&nbsp;library &raquo; <a href="https://www.npmjs.com/package/better-sqlite3">better-sqlite3</a>
<br />
&nbsp;library &raquo; <a href="https://www.npmjs.com/package/express">express</a>
<br />
&nbsp;library &raquo; <a href="https://www.npmjs.com/package/body-parser">body-parser</a>
<br />
&nbsp;library &raquo; <a href="https://www.npmjs.com/package/node-fetch">node-fetch</a>
<br />
&nbsp;library &raquo; <a href="https://www.npmjs.com/package/sharp">sharp</a>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close" data-onclick="closeAboutModal"></button>
</div>
`;
},
attachTo: app
});
const navbar = new Reef("#navbar", {
store: store,
template: (data) => {
return /*html*/`
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="#">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' data-name='Layer 1' viewBox='0 0 100 125' x='0px' y='0px'%3E%3Ctitle%3EArtboard 164%3C/title%3E%3Cpath d='M56.77,3.11a4,4,0,1,0-5.66,5.66l5.17,5.17L37.23,33A23.32,23.32,0,0,0,9.42,36.8L7.11,39.11a4,4,0,0,0,0,5.66l21.3,21.29L3.23,91.23a4,4,0,0,0,5.66,5.66L34.06,71.72l21,21a4,4,0,0,0,5.66,0l2.31-2.31a23.34,23.34,0,0,0,3.81-27.82l19-19,5.17,5.18a4,4,0,0,0,5.66-5.66Zm1.16,81.16L15.61,42a15.37,15.37,0,0,1,21.19.51L57.42,63.08A15.39,15.39,0,0,1,57.93,84.27Zm4-28L43.59,37.94,61.94,19.59,80.28,37.94Z'/%3E%3C/svg%3E" width="32" height="32" />
<!--<strong>TinyPin</strong>-->
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu">
<div class="navbar-start">
<span class="navbar-item" id="board-name">${data.board ? data.board.name : "Boards"}</span>
</div>
<div class="navbar-end">
<a class="navbar-item" data-onclick="openAddPinModal">
Add Pin
</a>
</div>
</div>
</nav>
`;
},
attachTo: app
});
const addPinModal = new Reef("#add-pin-modal", {
store: store,
template: (store) => {
let imagePlaceholder = 'data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22300%22%20height%3D%22300%22%3E%3Crect%20x%3D%222%22%20y%3D%222%22%20width%3D%22300%22%20height%3D%22300%22%20style%3D%22fill%3A%23dedede%3B%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20font-size%3D%2218%22%20text-anchor%3D%22middle%22%20alignment-baseline%3D%22middle%22%20font-family%3D%22monospace%2C%20sans-serif%22%20fill%3D%22%23555555%22%3Eimage%3C%2Ftext%3E%3C%2Fsvg%3E';
let options = "";
for ( let i = 0; i < store.boards.length; ++i ){
options += `<option value="${store.boards[i].id}">${store.boards[i].name}</option>`;
}
return /*html*/`
<div class="modal ${store.addPin.active ? 'is-active' : ''}">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Add Pin</p>
<button class="delete" aria-label="close" data-onclick="closeAddPinModal"></button>
</header>
<section class="modal-card-body">
<div class="add-pin-flex">
<div class="add-pin-flex-left">
<img id="add-pin-modal-img" src="${store.addPin.previewImageUrl ? store.addPin.previewImageUrl : imagePlaceholder}" />
</div>
<div class="add-pin-flex-right">
<form>
<div class="field">
<label class="label">Board</label>
<div class="select">
<select data-bind="addPin.boardId">
${options}
</select>
</div>
</div>
<div class="field">
<label class="label">Image Url</label>
<div class="control">
<input class="input" type="text" data-bind="addPin.imageUrl" data-onblur="updateAddPinPreview"/>
</div>
</div>
<div class="field">
<label class="label">Website Url</label>
<div class="control">
<input class="input" type="text" data-bind="addPin.siteUrl" />
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" data-bind="addPin.description"></textarea>
</div>
</div>
</form>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-success" data-onclick="saveAddPin">Add Pin</button>
</footer>
</div>
</div>
`;
},
attachTo: app
});
const pinZoomModal = new Reef("#pin-zoom-modal", {
store: store,
template: (data) => {
return /*html*/`
<div class="modal ${data.pinZoom.active ? 'is-active' : ''}" id="pin-zoom-modal" >
<div class="modal-background" data-onclick="closePinZoomModal"></div>
<div class="modal-content">
<p>
<img src="${data.pinZoom.active ? getOriginalImagePath(data.pinZoom.pinId) : ''}" />
</p>
</div>
<button class="modal-close is-large" aria-label="close" data-onclick="closePinZoomModal"></button>
</div>
`;
},
attachTo: app
});
const brickwall = new Reef('#brick-wall', {
store: store,
template: (data, el) => {
let numberOfColumns = 1;
let width = el.offsetWidth;
// matching bulma breakpoints - https://bulma.io/documentation/overview/responsiveness/
if( width >= 1216 ){
numberOfColumns = 5;
} else if ( width >= 1024 ){
numberOfColumns = 4;
} else if ( width >= 769 ){
numberOfColumns = 3;
} else if ( width > 320 ){
numberOfColumns = 2;
}
function createBrickForBoard(board){
return /*html*/`
<div class="brick board-brick">
<a href="#board=${board.id}">
<img src="${getThumbnailImagePath(board.titlePinId)}" />
<div class="board-brick-name">${board.name}</div>
</a>
</div>
`;
}
function createBrickForPin(board, pin){
return /*html*/`
<div class="brick" >
<a data-pinid="${pin.id}" data-onclick="openPinZoomModal">
<img src="${getThumbnailImagePath(pin.id)}" width="${pin.thumbnailWidth}" height="${pin.thumbnailHeight}" />
</a>
</div>
`;
}
// create the brick elements
let bricks = [];
if ( data.board ){
for ( let i = 0; i < data.board.pins.length; ++i ){
bricks.push(createBrickForPin(data.board, data.board.pins[i]));
}
} else {
for ( let i = 0; i < data.boards.length; ++i ){
bricks.push(createBrickForBoard(data.boards[i]));
}
}
// create column objects
let columns = [];
for ( let i = 0; i < numberOfColumns; ++i ){
columns[i] = {
height: 0,
bricks: []
}
}
// sort bricks into columns
for ( let i = 0; i < bricks.length; ++i ){
columns[i % columns.length].bricks.push(bricks[i]);
}
// write out the bricks
let result = "";
for ( let col = 0; col < columns.length; ++col ){
result += '<div class="brick-wall-column">';
for ( let i = 0; i < columns[col].bricks.length; ++i ){
result += columns[col].bricks[i];
}
result += '</div>';
}
return result;
},
attachTo: app
});
document.addEventListener('click', (el) => {
let target = el.target.closest('[data-onclick]');
if (target) {
let action = target.getAttribute('data-onclick');
if (action) {
if ( !actions[action] ){
console.error(`No action named ${action}`);
} else {
actions[action](target);
}
}
}
});
// focusout bubbles while 'blur' does not.
document.addEventListener('focusout', (el) => {
let target = el.target.closest('[data-onblur]');
if ( target ){
let method = target.getAttribute('data-onblur');
if ( method && typeof(actions[method]) === 'function') {
actions[method](target);
}
}
});
document.addEventListener('keyup', (el) => {
if ( store.data.pinZoom.active ){
if ( el.key == "Escape" ){
actions.closePinZoomModal();
} else if ( el.key == "ArrowLeft" ){
actions.movePinZoomModalLeft();
} else if ( el.key == "ArrowRight" ){
actions.movePinZoomModalRight();
}
}
if ( store.data.addPin.active ){
if ( el.key == "Escape" ){
actions.closeAddPinModal();
}
}
if ( store.data.about.active ){
if ( el.key == "Escape" ){
actions.closeAboutModal();
}
}
});
window.addEventListener('hashchange', (evt) => {
console.log("hash change");
handleHash();
});
window.addEventListener('resize', (evt) => {
app.render();
});
Reef.databind(app);
app.render();
loadBoards();
handleHash();
function handleHash(){
let hash = parseQueryString(window.location.hash.substr(1));
if ( hash.board ){
if ( !store.board || store.board.id != hash.board ){
loadBoard(hash.board);
}
} else {
store.data.board = null;
store.data.pinZoom.active = false;
store.data.addPin.active = false;
store.data.about.active = false;
}
}
async function loadBoard(id){
let res = await fetch("/api/boards/" + id);
store.data.board = await res.json();
}
async function loadBoards(){
let res = await fetch("/api/boards");
store.data.boards = await res.json();
}
function parseQueryString(qs){
let obj = {};
let parts = qs.split("&");
for ( let i = 0; i < parts.length; ++i ){
let kv = parts[i].split("=");
if ( kv.length == 2 ){
let key = decodeURIComponent(kv[0]);
let value = decodeURIComponent(kv[1]);
obj[key] = value;
}
}
return obj;
}
// image urls
function padId(id){
let result = id.toString();
while ( result.length < 12 ) {
result = '0' + result;
}
return result;
}
function getOriginalImagePath(pinId){
let paddedId = padId(pinId);
let dir = `originals/${paddedId[11]}/${paddedId[10]}/${paddedId[9]}/${paddedId[8]}`;
let file = `${dir}/${paddedId}.jpg`;
return file;
}
function getThumbnailImagePath(pinId){
let paddedId = padId(pinId);
let dir = `thumbnails/${paddedId[11]}/${paddedId[10]}/${paddedId[9]}/${paddedId[8]}`;
let file = `${dir}/${paddedId}.jpg`;
return file;
}

17
static/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>tinypin</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css" />
<link rel="stylesheet" href="client.css" />
</head>
<body>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/reefjs/dist/reef.min.js"></script>
<script src="reef-databind.js"></script>
<script src="client.js"></script>
</body>
</html>

1
static/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 100 125" x="0px" y="0px"><title>Artboard 164</title><path d="M56.77,3.11a4,4,0,1,0-5.66,5.66l5.17,5.17L37.23,33A23.32,23.32,0,0,0,9.42,36.8L7.11,39.11a4,4,0,0,0,0,5.66l21.3,21.29L3.23,91.23a4,4,0,0,0,5.66,5.66L34.06,71.72l21,21a4,4,0,0,0,5.66,0l2.31-2.31a23.34,23.34,0,0,0,3.81-27.82l19-19,5.17,5.18a4,4,0,0,0,5.66-5.66Zm1.16,81.16L15.61,42a15.37,15.37,0,0,1,21.19.51L57.42,63.08A15.39,15.39,0,0,1,57.93,84.27Zm4-28L43.59,37.94,61.94,19.59,80.28,37.94Z"/></svg>

After

Width:  |  Height:  |  Size: 534 B

298
static/reef-databind.js Normal file
View file

@ -0,0 +1,298 @@
// this currently will bind all fields with 'data-bind' attributes
// to the 'store'.
Reef.databind = function(reef){
let el = document.querySelector(reef.elem);
let store = reef.store;
if ( !store ){
console.log("Databind only works when using a store.");
return;
}
// bind all elements on the page that have a data-bind item
const bindData = debounce(() => {
let elems = el.querySelectorAll("[data-bind]");
for ( let i = 0; i < elems.length; ++i ){
let elem = elems[i];
let bindName = elem.getAttribute("data-bind");
if ( bindName ){
let val = get(store.data, bindName, "");
// dom values are strings, convert back so we can compare safely
if ( typeof(val) == 'array' ){
let strs = [];
for ( let i = 0; i < val.length; ++i ){
strs.push(val[i].toString());
}
val = strs;
} else {
val = val.toString();
}
// multiple selects need special handling
if ( elem.tagName == "SELECT" && elem.matches("[multiple]") ){
console.log("multiple");
let options = elem.querySelectorAll("option");
for ( let i = 0; i < options.length; ++i ){
if ( val.indexOf(options[i].value) > -1 ){
options[i].selected = true;
}
}
} else if ( elem.type == "radio" || elem.type == "checkbox" ){
if ( elem.value == val.toString() ){
elem.checked = true;
} else {
elem.checked = false;
}
} else {
elem.value = val;
}
}
}
});
el.addEventListener('input', (evt) => {
let target = evt.target;
let bindPath = target.getAttribute("data-bind");
if (bindPath) {
let val = target.value;
if ( target.type == "checkbox" ){
if ( ! target.checked ){
val = null;
}
}
// multiple selects need special handling
if ( target.tagName == 'SELECT' && target.matches("[multiple]") ){
val = [];
let options = target.querySelectorAll("option");
for ( let i = 0; i < options.length; ++i ){
if ( options[i].selected ){
console.log(options[i].value);
val.push(parseString(options[i].value));
}
}
}
// for checkbox, radio, and select fields - map numeric and boolean values to
// actual types instead of strings
else if ( target.type == 'checkbox' || target.type == 'radio' ){
val = parseString(val);
}
put(store.data, bindPath, val);
}
});
el.addEventListener('render', (evt) => {
bindData();
});
// convert booleans and numbers
function parseString(str){
if ( str == "true" ){
return true;
} else if ( str == "false" ){
return false;
} else {
return parseFloat(str) || str;
}
}
/**
* Debounce functions for better performance
* (c) 2018 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {Function} fn The function to debounce
*/
function debounce(fn) {
// Setup a timer
var timeout;
// Return a function to run debounced
return function () {
// Setup the arguments
var context = this;
var args = arguments;
// If there's a timer, cancel it
if (timeout) {
window.cancelAnimationFrame(timeout);
}
// Setup the new requestAnimationFrame()
timeout = window.requestAnimationFrame(function () {
fn.apply(context, args);
});
}
};
/*!
* Get an object value from a specific path
* (c) 2018 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {Object} obj The object
* @param {String|Array} path The path
* @param {*} def A default value to return [optional]
* @return {*} The value
*/
function get(obj, path, def) {
/**
* If the path is a string, convert it to an array
* @param {String|Array} path The path
* @return {Array} The path array
*/
var stringToPath = function (path) {
// If the path isn't a string, return it
if (typeof path !== 'string') return path;
// Create new array
var output = [];
// Split to an array with dot notation
path.split('.').forEach(function (item) {
// Split to an array with bracket notation
item.split(/\[([^}]+)\]/g).forEach(function (key) {
// Push to the new array
if (key.length > 0) {
output.push(key);
}
});
});
return output;
};
// Get the path as an array
path = stringToPath(path);
// Cache the current object
var current = obj;
// For each item in the path, dig into the object
for (var i = 0; i < path.length; i++) {
// If the item isn't found, return the default (or null)
if (!current[path[i]]) return def;
// Otherwise, update the current value
current = current[path[i]];
}
return current;
};
/*!
* Add items to an object at a specific path
* (c) 2018 Chris Ferdinandi, MIT License, https://gomakethings.com
* @param {Object} obj The object
* @param {String|Array} path The path to assign the value to
* @param {*} val The value to assign
*/
function put(obj, path, val) {
/**
* If the path is a string, convert it to an array
* @param {String|Array} path The path
* @return {Array} The path array
*/
var stringToPath = function (path) {
// If the path isn't a string, return it
if (typeof path !== 'string') return path;
// Create new array
var output = [];
// Split to an array with dot notation
path.split('.').forEach(function (item, index) {
// Split to an array with bracket notation
item.split(/\[([^}]+)\]/g).forEach(function (key) {
// Push to the new array
if (key.length > 0) {
output.push(key);
}
});
});
return output;
};
// Convert the path to an array if not already
path = stringToPath(path);
// Cache the path length and current spot in the object
var length = path.length;
var current = obj;
// Loop through the path
path.forEach(function (key, index) {
// Check if the assigned key shoul be an array
var isArray = key.slice(-2) === '[]';
// If so, get the true key name by removing the trailing []
key = isArray ? key.slice(0, -2) : key;
// If the key should be an array and isn't, create an array
if (isArray && Object.prototype.toString.call(current[key]) !== '[object Array]') {
current[key] = [];
}
// If this is the last item in the loop, assign the value
if (index === length - 1) {
// If it's an array, push the value
// Otherwise, assign it
if (isArray) {
current[key].push(val);
} else {
current[key] = val;
}
}
// Otherwise, update the current place in the object
else {
// If the key doesn't exist, create it
if (!current[key]) {
current[key] = {};
}
// Update the current place in the object
current = current[key];
}
});
};
}