s
This commit is contained in:
parent
70a315165f
commit
51766c1175
30 changed files with 5841 additions and 1627 deletions
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"secret": "TEST_SECRET",
|
||||
"https": false,
|
||||
"alter_db": true,
|
||||
"port": 8080,
|
||||
"db_url": "postgres://postgres:@127.0.0.1/todo",
|
||||
"cert": "",
|
||||
"cert_key": ""
|
||||
{
|
||||
"secret": "TEST_SECRET",
|
||||
"https": false,
|
||||
"alter_db": true,
|
||||
"port": 8080,
|
||||
"db_url": "postgres://postgres:@127.0.0.1/todo",
|
||||
"cert": "",
|
||||
"cert_key": ""
|
||||
}
|
|
@ -1,21 +1,21 @@
|
|||
const fs = require('fs');
|
||||
|
||||
if (!global.config) {
|
||||
global.config = {}
|
||||
const cfg = JSON.parse(fs.readFileSync('./config.json'));
|
||||
if (cfg) {
|
||||
global.config = cfg;
|
||||
}
|
||||
}
|
||||
|
||||
class Config {
|
||||
get config() {
|
||||
return global.config;
|
||||
}
|
||||
|
||||
set config(dat) {
|
||||
global.config = dat;
|
||||
}
|
||||
}
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
if (!global.config) {
|
||||
global.config = {}
|
||||
const cfg = JSON.parse(fs.readFileSync('./config.json'));
|
||||
if (cfg) {
|
||||
global.config = cfg;
|
||||
}
|
||||
}
|
||||
|
||||
class Config {
|
||||
get config() {
|
||||
return global.config;
|
||||
}
|
||||
|
||||
set config(dat) {
|
||||
global.config = dat;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Config();
|
|
@ -1,77 +1,77 @@
|
|||
const Sequelize = require('sequelize');
|
||||
const Config = require('./config.js');
|
||||
|
||||
if (!Config.config.db_url) {
|
||||
console.error("No database url found. please set `db_url` in config.json");
|
||||
process.exit();
|
||||
}
|
||||
|
||||
const db = new Sequelize(Config.config.db_url);
|
||||
|
||||
const User = db.define('User', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
password_hash: {
|
||||
type: Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
}
|
||||
});
|
||||
|
||||
const Todo = db.define('Todo', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
const Tag = db.define('Tag', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
User.hasMany(Todo);
|
||||
Todo.hasMany(Tag);
|
||||
|
||||
let options = {
|
||||
alter: false
|
||||
};
|
||||
if (Config.config.alter_db) {
|
||||
options.alter = true;
|
||||
}
|
||||
|
||||
User.sync(options);
|
||||
module.exports = {
|
||||
db: db,
|
||||
constructors: {
|
||||
user: () => { return User.build(); }
|
||||
},
|
||||
schemas: {
|
||||
user: User
|
||||
}
|
||||
const Sequelize = require('sequelize');
|
||||
const Config = require('./config.js');
|
||||
|
||||
if (!Config.config.db_url) {
|
||||
console.error("No database url found. please set `db_url` in config.json");
|
||||
process.exit();
|
||||
}
|
||||
|
||||
const db = new Sequelize(Config.config.db_url);
|
||||
|
||||
const User = db.define('User', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
password_hash: {
|
||||
type: Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
}
|
||||
});
|
||||
|
||||
const Todo = db.define('Todo', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
const Tag = db.define('Tag', {
|
||||
id: {
|
||||
type: Sequelize.DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
content: {
|
||||
type: Sequelize.DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
User.hasMany(Todo);
|
||||
Todo.hasMany(Tag);
|
||||
|
||||
let options = {
|
||||
alter: false
|
||||
};
|
||||
if (Config.config.alter_db) {
|
||||
options.alter = true;
|
||||
}
|
||||
|
||||
User.sync(options);
|
||||
module.exports = {
|
||||
db: db,
|
||||
constructors: {
|
||||
user: () => { return User.build(); }
|
||||
},
|
||||
schemas: {
|
||||
user: User
|
||||
}
|
||||
}
|
|
@ -1,62 +1,62 @@
|
|||
const http = require('http');
|
||||
const https = require('https');
|
||||
const cors = require('cors');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const Config = require('./config.js');
|
||||
|
||||
const UserInterface = require('./user.js');
|
||||
|
||||
let credentials = {};
|
||||
|
||||
if (Config.config.https) {
|
||||
if (
|
||||
fs.existsSync(Config.config.cert) &&
|
||||
fs.existsSync(Config.config.cert_key)
|
||||
) {
|
||||
credentials.key = fs.readFileSync(Config.config.cert_key);
|
||||
credentials.cert = fs.readFileSync(Config.config.cert);
|
||||
}
|
||||
}
|
||||
|
||||
let app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
|
||||
// force https
|
||||
app.use((req, res, next) => {
|
||||
if (Config.config.https) {
|
||||
if (req.headers['x-forwarded-proto'] !== 'https') {
|
||||
return res.redirect(`https://${req.headers.host}${req.url}`);
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
if (!Config.config.secret) {
|
||||
console.error('No password secret found. please set `secret` in config.json');
|
||||
process.exit();
|
||||
} else if (Config.config.https && Config.config.secret == 'TEST_SECRET') {
|
||||
console.error('please do not use the testing secret in production.');
|
||||
process.exit();
|
||||
}
|
||||
|
||||
app.use('/api/user', UserInterface.router);
|
||||
|
||||
// serve static files last
|
||||
app.use(express.static('./static'));
|
||||
// DISABLED: no longer needs to serve static files
|
||||
// due to frontend being employed in elm
|
||||
|
||||
if (Config.config.https) {
|
||||
var server = https.createServer(credentials, app);
|
||||
server.listen(Config.config.port || 8080);
|
||||
} else {
|
||||
var server = http.createServer(app);
|
||||
server.listen(Config.config.port || 8080);
|
||||
}
|
||||
console.log(
|
||||
`listening on port ${Config.config.port || 8080}` +
|
||||
` with https ${Config.config.https ? 'enabled' : 'disabled'}`
|
||||
);
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const cors = require('cors');
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const Config = require('./config.js');
|
||||
|
||||
const UserInterface = require('./user.js');
|
||||
|
||||
let credentials = {};
|
||||
|
||||
if (Config.config.https) {
|
||||
if (
|
||||
fs.existsSync(Config.config.cert) &&
|
||||
fs.existsSync(Config.config.cert_key)
|
||||
) {
|
||||
credentials.key = fs.readFileSync(Config.config.cert_key);
|
||||
credentials.cert = fs.readFileSync(Config.config.cert);
|
||||
}
|
||||
}
|
||||
|
||||
let app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
|
||||
// force https
|
||||
app.use((req, res, next) => {
|
||||
if (Config.config.https) {
|
||||
if (req.headers['x-forwarded-proto'] !== 'https') {
|
||||
return res.redirect(`https://${req.headers.host}${req.url}`);
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
if (!Config.config.secret) {
|
||||
console.error('No password secret found. please set `secret` in config.json');
|
||||
process.exit();
|
||||
} else if (Config.config.https && Config.config.secret == 'TEST_SECRET') {
|
||||
console.error('please do not use the testing secret in production.');
|
||||
process.exit();
|
||||
}
|
||||
|
||||
app.use('/api/user', UserInterface.router);
|
||||
|
||||
// serve static files last
|
||||
app.use(express.static('./static'));
|
||||
// DISABLED: no longer needs to serve static files
|
||||
// due to frontend being employed in elm
|
||||
|
||||
if (Config.config.https) {
|
||||
var server = https.createServer(credentials, app);
|
||||
server.listen(Config.config.port || 8080);
|
||||
} else {
|
||||
var server = http.createServer(app);
|
||||
server.listen(Config.config.port || 8080);
|
||||
}
|
||||
console.log(
|
||||
`listening on port ${Config.config.port || 8080}` +
|
||||
` with https ${Config.config.https ? 'enabled' : 'disabled'}`
|
||||
);
|
|
@ -1,235 +1,235 @@
|
|||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const Config = require('./config.js');
|
||||
const Database = require('./db_interface.js');
|
||||
|
||||
let router = express.Router();
|
||||
|
||||
router.use(express.json());
|
||||
|
||||
let session_entropy = {};
|
||||
|
||||
user_cache = {};
|
||||
email_cache = {};
|
||||
|
||||
async function get_user_details(id) {
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
console.log(`search for user with id ${id}`);
|
||||
if (!user_cache[id]) {
|
||||
let user = await Database.schemas.user.findOne({where: {id: id}});
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
user_cache[user.id] = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
password_hash: user.password_hash,
|
||||
};
|
||||
email_cache[user.email] = user.id;
|
||||
}
|
||||
console.log(`returning ${JSON.stringify(user_cache[id])}`);
|
||||
return user_cache[id];
|
||||
}
|
||||
|
||||
async function get_user_details_by_email(email) {
|
||||
if (!email) {
|
||||
return undefined;
|
||||
}
|
||||
console.log(`search for user with email ${email}}`);
|
||||
if (!email_cache[email] || !user_cache[email_cache[email]]) {
|
||||
let user = await Database.schemas.user.findOne({where: {email: email}});
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
user_cache[user.id] = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
password_hash: user.password_hash,
|
||||
};
|
||||
email_cache[user.email] = user.id;
|
||||
}
|
||||
console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`);
|
||||
return user_cache[email_cache[email]];
|
||||
}
|
||||
|
||||
router.get('/byEmail/:email', async (req, res) => {
|
||||
if (!req.params?.email) {
|
||||
res.status(400).json({
|
||||
error: 'email is a required parameter',
|
||||
});
|
||||
}
|
||||
let user = get_user_details_by_email(req.params.email);
|
||||
console.log(user);
|
||||
if (user != null) {
|
||||
res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
} else {
|
||||
res.sendStatus(404);
|
||||
}
|
||||
});
|
||||
|
||||
function hash(secret, password) {
|
||||
let pw_hash = crypto.pbkdf2Sync(
|
||||
password,
|
||||
secret,
|
||||
Config.config.key?.iterations || 1000,
|
||||
Config.config.key?.length || 64,
|
||||
'sha512'
|
||||
);
|
||||
|
||||
return pw_hash.toString('base64');
|
||||
}
|
||||
|
||||
function verify(secret, password, hash) {
|
||||
let pw_hash = crypto.pbkdf2Sync(
|
||||
password,
|
||||
secret,
|
||||
Config.config.key?.iterations || 1000,
|
||||
Config.config.key?.length || 64,
|
||||
'sha512'
|
||||
);
|
||||
|
||||
return hash === pw_hash.toString('base64');
|
||||
}
|
||||
|
||||
function hash_password(password) {
|
||||
return hash(Config.config.secret, password);
|
||||
}
|
||||
|
||||
function verify_password(password, hash) {
|
||||
return verify(Config.config.secret, password, hash);
|
||||
}
|
||||
|
||||
function get_session_token(id, token) {
|
||||
session_entropy[id] = crypto.randomBytes(Config.config.session_entropy || 32);
|
||||
return hash(session_entropy[id], token);
|
||||
}
|
||||
|
||||
function verify_session_token(id, hash, token) {
|
||||
if (session_entropy[id]) {
|
||||
return verify(session_entropy[id], hash, token);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enforce_session_login(req, res, next) {
|
||||
let userid = req.cookies?.userid;
|
||||
let session_token = req.cookies?._session;
|
||||
console.log('a', userid, session_token);
|
||||
if (!userid || !session_token) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
let user = await get_user_details(userid);
|
||||
if (!user) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
let verified_session = verify_session_token(
|
||||
userid,
|
||||
user.password_hash,
|
||||
session_token
|
||||
);
|
||||
if (!verified_session) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.post('/new', async (req, res) => {
|
||||
if (!req.body?.email || !req.body?.password) {
|
||||
return res.status(400).json({
|
||||
error: 'must have email and password fields',
|
||||
});
|
||||
}
|
||||
let user = await get_user_details_by_email(req.body.email);
|
||||
console.log(user);
|
||||
if (user != null) {
|
||||
return res.status(403).json({
|
||||
error: `email ${req.body.email} is already in use.`,
|
||||
});
|
||||
} else {
|
||||
let user = await Database.schemas.user.create({
|
||||
email: String(req.body.email),
|
||||
password_hash: hash_password(req.body.password),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
if (!req.body?.email || !req.body?.password) {
|
||||
return res.status(400).json({
|
||||
error: 'must have email and password fields',
|
||||
});
|
||||
}
|
||||
let user = await get_user_details_by_email(req.body.email);
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
error: 'incorrect email or password',
|
||||
});
|
||||
}
|
||||
let verified = verify_password(req.body.password, user.password_hash);
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({
|
||||
error: 'incorrect email or password',
|
||||
});
|
||||
}
|
||||
|
||||
res.cookie('userid', user.id, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
});
|
||||
res.cookie('_session', get_session_token(user.id, user.password_hash), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
});
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.get('/:id([a-f0-9-]+)', async (req, res) => {
|
||||
console.log(req.params);
|
||||
if (!req.params?.id) {
|
||||
return res.status(400).json({
|
||||
error: 'must have id parameter',
|
||||
});
|
||||
}
|
||||
let id = req.params?.id;
|
||||
console.log(id);
|
||||
let user = await get_user_details(id);
|
||||
console.log(user);
|
||||
if (user != null) {
|
||||
return res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
} else {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
});
|
||||
|
||||
router.use('/authorized', enforce_session_login);
|
||||
router.get('/authorized', async (req, res) => {
|
||||
let userid = req.cookies?.userid;
|
||||
let user = await get_user_details(userid);
|
||||
return res.json({
|
||||
authorized: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
router: router,
|
||||
enforce_session_login: enforce_session_login,
|
||||
};
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const Config = require('./config.js');
|
||||
const Database = require('./db_interface.js');
|
||||
|
||||
let router = express.Router();
|
||||
|
||||
router.use(express.json());
|
||||
|
||||
let session_entropy = {};
|
||||
|
||||
user_cache = {};
|
||||
email_cache = {};
|
||||
|
||||
async function get_user_details(id) {
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
console.log(`search for user with id ${id}`);
|
||||
if (!user_cache[id]) {
|
||||
let user = await Database.schemas.user.findOne({where: {id: id}});
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
user_cache[user.id] = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
password_hash: user.password_hash,
|
||||
};
|
||||
email_cache[user.email] = user.id;
|
||||
}
|
||||
console.log(`returning ${JSON.stringify(user_cache[id])}`);
|
||||
return user_cache[id];
|
||||
}
|
||||
|
||||
async function get_user_details_by_email(email) {
|
||||
if (!email) {
|
||||
return undefined;
|
||||
}
|
||||
console.log(`search for user with email ${email}}`);
|
||||
if (!email_cache[email] || !user_cache[email_cache[email]]) {
|
||||
let user = await Database.schemas.user.findOne({where: {email: email}});
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
user_cache[user.id] = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
password_hash: user.password_hash,
|
||||
};
|
||||
email_cache[user.email] = user.id;
|
||||
}
|
||||
console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`);
|
||||
return user_cache[email_cache[email]];
|
||||
}
|
||||
|
||||
router.get('/byEmail/:email', async (req, res) => {
|
||||
if (!req.params?.email) {
|
||||
res.status(400).json({
|
||||
error: 'email is a required parameter',
|
||||
});
|
||||
}
|
||||
let user = get_user_details_by_email(req.params.email);
|
||||
console.log(user);
|
||||
if (user != null) {
|
||||
res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
} else {
|
||||
res.sendStatus(404);
|
||||
}
|
||||
});
|
||||
|
||||
function hash(secret, password) {
|
||||
let pw_hash = crypto.pbkdf2Sync(
|
||||
password,
|
||||
secret,
|
||||
Config.config.key?.iterations || 1000,
|
||||
Config.config.key?.length || 64,
|
||||
'sha512'
|
||||
);
|
||||
|
||||
return pw_hash.toString('base64');
|
||||
}
|
||||
|
||||
function verify(secret, password, hash) {
|
||||
let pw_hash = crypto.pbkdf2Sync(
|
||||
password,
|
||||
secret,
|
||||
Config.config.key?.iterations || 1000,
|
||||
Config.config.key?.length || 64,
|
||||
'sha512'
|
||||
);
|
||||
|
||||
return hash === pw_hash.toString('base64');
|
||||
}
|
||||
|
||||
function hash_password(password) {
|
||||
return hash(Config.config.secret, password);
|
||||
}
|
||||
|
||||
function verify_password(password, hash) {
|
||||
return verify(Config.config.secret, password, hash);
|
||||
}
|
||||
|
||||
function get_session_token(id, token) {
|
||||
session_entropy[id] = crypto.randomBytes(Config.config.session_entropy || 32);
|
||||
return hash(session_entropy[id], token);
|
||||
}
|
||||
|
||||
function verify_session_token(id, hash, token) {
|
||||
if (session_entropy[id]) {
|
||||
return verify(session_entropy[id], hash, token);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enforce_session_login(req, res, next) {
|
||||
let userid = req.cookies?.userid;
|
||||
let session_token = req.cookies?._session;
|
||||
console.log('a', userid, session_token);
|
||||
if (!userid || !session_token) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
let user = await get_user_details(userid);
|
||||
if (!user) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
let verified_session = verify_session_token(
|
||||
userid,
|
||||
user.password_hash,
|
||||
session_token
|
||||
);
|
||||
if (!verified_session) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.post('/new', async (req, res) => {
|
||||
if (!req.body?.email || !req.body?.password) {
|
||||
return res.status(400).json({
|
||||
error: 'must have email and password fields',
|
||||
});
|
||||
}
|
||||
let user = await get_user_details_by_email(req.body.email);
|
||||
console.log(user);
|
||||
if (user != null) {
|
||||
return res.status(403).json({
|
||||
error: `email ${req.body.email} is already in use.`,
|
||||
});
|
||||
} else {
|
||||
let user = await Database.schemas.user.create({
|
||||
email: String(req.body.email),
|
||||
password_hash: hash_password(req.body.password),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
if (!req.body?.email || !req.body?.password) {
|
||||
return res.status(400).json({
|
||||
error: 'must have email and password fields',
|
||||
});
|
||||
}
|
||||
let user = await get_user_details_by_email(req.body.email);
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
error: 'incorrect email or password',
|
||||
});
|
||||
}
|
||||
let verified = verify_password(req.body.password, user.password_hash);
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({
|
||||
error: 'incorrect email or password',
|
||||
});
|
||||
}
|
||||
|
||||
res.cookie('userid', user.id, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
});
|
||||
res.cookie('_session', get_session_token(user.id, user.password_hash), {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
});
|
||||
return res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.get('/:id([a-f0-9-]+)', async (req, res) => {
|
||||
console.log(req.params);
|
||||
if (!req.params?.id) {
|
||||
return res.status(400).json({
|
||||
error: 'must have id parameter',
|
||||
});
|
||||
}
|
||||
let id = req.params?.id;
|
||||
console.log(id);
|
||||
let user = await get_user_details(id);
|
||||
console.log(user);
|
||||
if (user != null) {
|
||||
return res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
} else {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
});
|
||||
|
||||
router.use('/authorized', enforce_session_login);
|
||||
router.get('/authorized', async (req, res) => {
|
||||
let userid = req.cookies?.userid;
|
||||
let user = await get_user_details(userid);
|
||||
return res.json({
|
||||
authorized: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
router: router,
|
||||
enforce_session_login: enforce_session_login,
|
||||
};
|
|
@ -1,290 +1,290 @@
|
|||
port module Api exposing (Cred, addServerError, application, decodeErrors, delete, get, login, logout, post, put, register, settings, storeCredWith, username, viewerChanges)
|
||||
|
||||
{-| This module is responsible for communicating to the Conduit API.
|
||||
It exposes an opaque Endpoint type which is guaranteed to point to the correct URL.
|
||||
-}
|
||||
|
||||
import Api.Endpoint as Endpoint exposing (Endpoint)
|
||||
import Avatar exposing (Avatar)
|
||||
import Browser
|
||||
import Browser.Navigation as Nav
|
||||
import Http exposing (Body, Expect)
|
||||
import Json.Decode as Decode exposing (Decoder, Value, decodeString, field, string)
|
||||
import Json.Encode as Encode
|
||||
import Url exposing (Url)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
|
||||
-- CRED
|
||||
|
||||
|
||||
{-| The authentication credentials for the Viewer (that is, the currently logged-in user.)
|
||||
This includes:
|
||||
|
||||
- The cred's Username
|
||||
- The cred's authentication token
|
||||
By design, there is no way to access the token directly as a String.
|
||||
It can be encoded for persistence, and it can be added to a header
|
||||
to a HttpBuilder for a request, but that's it.
|
||||
This token should never be rendered to the end user, and with this API, it
|
||||
can't be!
|
||||
|
||||
-}
|
||||
type Cred
|
||||
= Cred Username String
|
||||
|
||||
|
||||
username : Cred -> Username
|
||||
username (Cred val _) =
|
||||
val
|
||||
|
||||
|
||||
credHeader : Cred -> Http.Header
|
||||
credHeader (Cred _ str) =
|
||||
Http.header "authorization" ("Token " ++ str)
|
||||
|
||||
|
||||
{-| It's important that this is never exposed!
|
||||
We expose `login` and `application` instead, so we can be certain that if anyone
|
||||
ever has access to a `Cred` value, it came from either the login API endpoint
|
||||
or was passed in via flags.
|
||||
-}
|
||||
credDecoder : Decoder Cred
|
||||
credDecoder =
|
||||
Decode.succeed Cred
|
||||
|> field "username" Username.decoder
|
||||
|> field "token" Decode.string
|
||||
|
||||
|
||||
|
||||
-- PERSISTENCE
|
||||
|
||||
|
||||
decode : Decoder (Cred -> viewer) -> Value -> Result Decode.Error viewer
|
||||
decode decoder value =
|
||||
-- It's stored in localStorage as a JSON String;
|
||||
-- first decode the Value as a String, then
|
||||
-- decode that String as JSON.
|
||||
Decode.decodeValue Decode.string value
|
||||
|> Result.andThen (\str -> Decode.decodeString (Decode.field "user" (decoderFromCred decoder)) str)
|
||||
|
||||
|
||||
port onStoreChange : (Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
viewerChanges : (Maybe viewer -> msg) -> Decoder (Cred -> viewer) -> Sub msg
|
||||
viewerChanges toMsg decoder =
|
||||
onStoreChange (\value -> toMsg (decodeFromChange decoder value))
|
||||
|
||||
|
||||
decodeFromChange : Decoder (Cred -> viewer) -> Value -> Maybe viewer
|
||||
decodeFromChange viewerDecoder val =
|
||||
-- It's stored in localStorage as a JSON String;
|
||||
-- first decode the Value as a String, then
|
||||
-- decode that String as JSON.
|
||||
Decode.decodeValue (storageDecoder viewerDecoder) val
|
||||
|> Result.toMaybe
|
||||
|
||||
|
||||
storeCredWith : Cred -> Avatar -> Cmd msg
|
||||
storeCredWith (Cred uname token) avatar =
|
||||
let
|
||||
json =
|
||||
Encode.object
|
||||
[ ( "user"
|
||||
, Encode.object
|
||||
[ ( "username", Username.encode uname )
|
||||
, ( "token", Encode.string token )
|
||||
, ( "image", Avatar.encode avatar )
|
||||
]
|
||||
)
|
||||
]
|
||||
in
|
||||
storeCache (Just json)
|
||||
|
||||
|
||||
logout : Cmd msg
|
||||
logout =
|
||||
storeCache Nothing
|
||||
|
||||
|
||||
port storeCache : Maybe Value -> Cmd msg
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
-- APPLICATION
|
||||
|
||||
|
||||
application :
|
||||
Decoder (Cred -> viewer)
|
||||
->
|
||||
{ init : Maybe viewer -> Url -> Nav.Key -> ( model, Cmd msg )
|
||||
, onUrlChange : Url -> msg
|
||||
, onUrlRequest : Browser.UrlRequest -> msg
|
||||
, subscriptions : model -> Sub msg
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Browser.Document msg
|
||||
}
|
||||
-> Program Value model msg
|
||||
application viewerDecoder config =
|
||||
let
|
||||
init flags url navKey =
|
||||
let
|
||||
maybeViewer =
|
||||
Decode.decodeValue Decode.string flags
|
||||
|> Result.andThen (Decode.decodeString (storageDecoder viewerDecoder))
|
||||
|> Result.toMaybe
|
||||
in
|
||||
config.init maybeViewer url navKey
|
||||
in
|
||||
Browser.application
|
||||
{ init = init
|
||||
, onUrlChange = config.onUrlChange
|
||||
, onUrlRequest = config.onUrlRequest
|
||||
, subscriptions = config.subscriptions
|
||||
, update = config.update
|
||||
, view = config.view
|
||||
}
|
||||
|
||||
|
||||
storageDecoder : Decoder (Cred -> viewer) -> Decoder viewer
|
||||
storageDecoder viewerDecoder =
|
||||
Decode.field "user" (decoderFromCred viewerDecoder)
|
||||
|
||||
|
||||
|
||||
-- HTTP
|
||||
|
||||
|
||||
get : Endpoint -> Maybe Cred -> Decoder a -> Cmd a
|
||||
get url maybeCred decoder =
|
||||
Endpoint.request
|
||||
{ method = "GET"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers =
|
||||
case maybeCred of
|
||||
Just cred ->
|
||||
[ credHeader cred ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
, body = Http.emptyBody
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
put : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
|
||||
put url cred body decoder =
|
||||
Endpoint.request
|
||||
{ method = "PUT"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers = [ credHeader cred ]
|
||||
, body = body
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
post : Endpoint -> Maybe Cred -> Body -> Decoder a -> Cmd a
|
||||
post url maybeCred body decoder =
|
||||
Endpoint.request
|
||||
{ method = "POST"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers =
|
||||
case maybeCred of
|
||||
Just cred ->
|
||||
[ credHeader cred ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
, body = body
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
delete : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
|
||||
delete url cred body decoder =
|
||||
Endpoint.request
|
||||
{ method = "DELETE"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers = [ credHeader cred ]
|
||||
, body = body
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
login : Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||
login body decoder =
|
||||
post Endpoint.login Nothing body (Decode.field "user" (decoderFromCred decoder))
|
||||
|
||||
|
||||
register : Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||
register body decoder =
|
||||
post Endpoint.users Nothing body (Decode.field "user" (decoderFromCred decoder))
|
||||
|
||||
|
||||
settings : Cred -> Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||
settings cred body decoder =
|
||||
put Endpoint.user cred body (Decode.field "user" (decoderFromCred decoder))
|
||||
|
||||
|
||||
decoderFromCred : Decoder (Cred -> a) -> Decoder a
|
||||
decoderFromCred decoder =
|
||||
Decode.map2 (\fromCred cred -> fromCred cred)
|
||||
decoder
|
||||
credDecoder
|
||||
|
||||
|
||||
|
||||
-- ERRORS
|
||||
|
||||
|
||||
addServerError : List String -> List String
|
||||
addServerError list =
|
||||
"Server error" :: list
|
||||
|
||||
|
||||
{-| Many API endpoints include an "errors" field in their BadStatus responses.
|
||||
-}
|
||||
decodeErrors : Http.Error -> List String
|
||||
decodeErrors error =
|
||||
case error of
|
||||
Http.BadStatus errid ->
|
||||
[ Int.toString errid ]
|
||||
|
||||
err ->
|
||||
[ "Server error" ]
|
||||
|
||||
|
||||
errorsDecoder : Decoder (List String)
|
||||
errorsDecoder =
|
||||
Decode.keyValuePairs (Decode.list Decode.string)
|
||||
|> Decode.map (List.concatMap fromPair)
|
||||
|
||||
|
||||
fromPair : ( String, List String ) -> List String
|
||||
fromPair ( field, errors ) =
|
||||
List.map (\error -> field ++ " " ++ error) errors
|
||||
|
||||
|
||||
|
||||
-- LOCALSTORAGE KEYS
|
||||
|
||||
|
||||
cacheStorageKey : String
|
||||
cacheStorageKey =
|
||||
"cache"
|
||||
|
||||
|
||||
credStorageKey : String
|
||||
credStorageKey =
|
||||
"cred"
|
||||
port module Api exposing (Cred, addServerError, application, decodeErrors, delete, get, login, logout, post, put, register, settings, storeCredWith, username, viewerChanges)
|
||||
|
||||
{-| This module is responsible for communicating to the Conduit API.
|
||||
It exposes an opaque Endpoint type which is guaranteed to point to the correct URL.
|
||||
-}
|
||||
|
||||
import Api.Endpoint as Endpoint exposing (Endpoint)
|
||||
import Avatar exposing (Avatar)
|
||||
import Browser
|
||||
import Browser.Navigation as Nav
|
||||
import Http exposing (Body, Expect)
|
||||
import Json.Decode as Decode exposing (Decoder, Value, decodeString, field, string)
|
||||
import Json.Encode as Encode
|
||||
import Url exposing (Url)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
|
||||
-- CRED
|
||||
|
||||
|
||||
{-| The authentication credentials for the Viewer (that is, the currently logged-in user.)
|
||||
This includes:
|
||||
|
||||
- The cred's Username
|
||||
- The cred's authentication token
|
||||
By design, there is no way to access the token directly as a String.
|
||||
It can be encoded for persistence, and it can be added to a header
|
||||
to a HttpBuilder for a request, but that's it.
|
||||
This token should never be rendered to the end user, and with this API, it
|
||||
can't be!
|
||||
|
||||
-}
|
||||
type Cred
|
||||
= Cred Username String
|
||||
|
||||
|
||||
username : Cred -> Username
|
||||
username (Cred val _) =
|
||||
val
|
||||
|
||||
|
||||
credHeader : Cred -> Http.Header
|
||||
credHeader (Cred _ str) =
|
||||
Http.header "authorization" ("Token " ++ str)
|
||||
|
||||
|
||||
{-| It's important that this is never exposed!
|
||||
We expose `login` and `application` instead, so we can be certain that if anyone
|
||||
ever has access to a `Cred` value, it came from either the login API endpoint
|
||||
or was passed in via flags.
|
||||
-}
|
||||
credDecoder : Decoder Cred
|
||||
credDecoder =
|
||||
Decode.succeed Cred
|
||||
|> field "username" Username.decoder
|
||||
|> field "token" Decode.string
|
||||
|
||||
|
||||
|
||||
-- PERSISTENCE
|
||||
|
||||
|
||||
decode : Decoder (Cred -> viewer) -> Value -> Result Decode.Error viewer
|
||||
decode decoder value =
|
||||
-- It's stored in localStorage as a JSON String;
|
||||
-- first decode the Value as a String, then
|
||||
-- decode that String as JSON.
|
||||
Decode.decodeValue Decode.string value
|
||||
|> Result.andThen (\str -> Decode.decodeString (Decode.field "user" (decoderFromCred decoder)) str)
|
||||
|
||||
|
||||
port onStoreChange : (Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
viewerChanges : (Maybe viewer -> msg) -> Decoder (Cred -> viewer) -> Sub msg
|
||||
viewerChanges toMsg decoder =
|
||||
onStoreChange (\value -> toMsg (decodeFromChange decoder value))
|
||||
|
||||
|
||||
decodeFromChange : Decoder (Cred -> viewer) -> Value -> Maybe viewer
|
||||
decodeFromChange viewerDecoder val =
|
||||
-- It's stored in localStorage as a JSON String;
|
||||
-- first decode the Value as a String, then
|
||||
-- decode that String as JSON.
|
||||
Decode.decodeValue (storageDecoder viewerDecoder) val
|
||||
|> Result.toMaybe
|
||||
|
||||
|
||||
storeCredWith : Cred -> Avatar -> Cmd msg
|
||||
storeCredWith (Cred uname token) avatar =
|
||||
let
|
||||
json =
|
||||
Encode.object
|
||||
[ ( "user"
|
||||
, Encode.object
|
||||
[ ( "username", Username.encode uname )
|
||||
, ( "token", Encode.string token )
|
||||
, ( "image", Avatar.encode avatar )
|
||||
]
|
||||
)
|
||||
]
|
||||
in
|
||||
storeCache (Just json)
|
||||
|
||||
|
||||
logout : Cmd msg
|
||||
logout =
|
||||
storeCache Nothing
|
||||
|
||||
|
||||
port storeCache : Maybe Value -> Cmd msg
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
-- APPLICATION
|
||||
|
||||
|
||||
application :
|
||||
Decoder (Cred -> viewer)
|
||||
->
|
||||
{ init : Maybe viewer -> Url -> Nav.Key -> ( model, Cmd msg )
|
||||
, onUrlChange : Url -> msg
|
||||
, onUrlRequest : Browser.UrlRequest -> msg
|
||||
, subscriptions : model -> Sub msg
|
||||
, update : msg -> model -> ( model, Cmd msg )
|
||||
, view : model -> Browser.Document msg
|
||||
}
|
||||
-> Program Value model msg
|
||||
application viewerDecoder config =
|
||||
let
|
||||
init flags url navKey =
|
||||
let
|
||||
maybeViewer =
|
||||
Decode.decodeValue Decode.string flags
|
||||
|> Result.andThen (Decode.decodeString (storageDecoder viewerDecoder))
|
||||
|> Result.toMaybe
|
||||
in
|
||||
config.init maybeViewer url navKey
|
||||
in
|
||||
Browser.application
|
||||
{ init = init
|
||||
, onUrlChange = config.onUrlChange
|
||||
, onUrlRequest = config.onUrlRequest
|
||||
, subscriptions = config.subscriptions
|
||||
, update = config.update
|
||||
, view = config.view
|
||||
}
|
||||
|
||||
|
||||
storageDecoder : Decoder (Cred -> viewer) -> Decoder viewer
|
||||
storageDecoder viewerDecoder =
|
||||
Decode.field "user" (decoderFromCred viewerDecoder)
|
||||
|
||||
|
||||
|
||||
-- HTTP
|
||||
|
||||
|
||||
get : Endpoint -> Maybe Cred -> Decoder a -> Cmd a
|
||||
get url maybeCred decoder =
|
||||
Endpoint.request
|
||||
{ method = "GET"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers =
|
||||
case maybeCred of
|
||||
Just cred ->
|
||||
[ credHeader cred ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
, body = Http.emptyBody
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
put : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
|
||||
put url cred body decoder =
|
||||
Endpoint.request
|
||||
{ method = "PUT"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers = [ credHeader cred ]
|
||||
, body = body
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
post : Endpoint -> Maybe Cred -> Body -> Decoder a -> Cmd a
|
||||
post url maybeCred body decoder =
|
||||
Endpoint.request
|
||||
{ method = "POST"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers =
|
||||
case maybeCred of
|
||||
Just cred ->
|
||||
[ credHeader cred ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
, body = body
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
delete : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
|
||||
delete url cred body decoder =
|
||||
Endpoint.request
|
||||
{ method = "DELETE"
|
||||
, url = url
|
||||
, expect = Http.expectJson decoder
|
||||
, headers = [ credHeader cred ]
|
||||
, body = body
|
||||
, timeout = Nothing
|
||||
, withCredentials = False
|
||||
}
|
||||
|
||||
|
||||
login : Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||
login body decoder =
|
||||
post Endpoint.login Nothing body (Decode.field "user" (decoderFromCred decoder))
|
||||
|
||||
|
||||
register : Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||
register body decoder =
|
||||
post Endpoint.users Nothing body (Decode.field "user" (decoderFromCred decoder))
|
||||
|
||||
|
||||
settings : Cred -> Http.Body -> Decoder (Cred -> a) -> Cmd a
|
||||
settings cred body decoder =
|
||||
put Endpoint.user cred body (Decode.field "user" (decoderFromCred decoder))
|
||||
|
||||
|
||||
decoderFromCred : Decoder (Cred -> a) -> Decoder a
|
||||
decoderFromCred decoder =
|
||||
Decode.map2 (\fromCred cred -> fromCred cred)
|
||||
decoder
|
||||
credDecoder
|
||||
|
||||
|
||||
|
||||
-- ERRORS
|
||||
|
||||
|
||||
addServerError : List String -> List String
|
||||
addServerError list =
|
||||
"Server error" :: list
|
||||
|
||||
|
||||
{-| Many API endpoints include an "errors" field in their BadStatus responses.
|
||||
-}
|
||||
decodeErrors : Http.Error -> List String
|
||||
decodeErrors error =
|
||||
case error of
|
||||
Http.BadStatus errid ->
|
||||
[ Int.toString errid ]
|
||||
|
||||
err ->
|
||||
[ "Server error" ]
|
||||
|
||||
|
||||
errorsDecoder : Decoder (List String)
|
||||
errorsDecoder =
|
||||
Decode.keyValuePairs (Decode.list Decode.string)
|
||||
|> Decode.map (List.concatMap fromPair)
|
||||
|
||||
|
||||
fromPair : ( String, List String ) -> List String
|
||||
fromPair ( field, errors ) =
|
||||
List.map (\error -> field ++ " " ++ error) errors
|
||||
|
||||
|
||||
|
||||
-- LOCALSTORAGE KEYS
|
||||
|
||||
|
||||
cacheStorageKey : String
|
||||
cacheStorageKey =
|
||||
"cache"
|
||||
|
||||
|
||||
credStorageKey : String
|
||||
credStorageKey =
|
||||
"cred"
|
||||
|
|
|
@ -1,99 +1,99 @@
|
|||
module Api.Endpoint exposing (Endpoint, login, request, tags, todo, todoList, user, users)
|
||||
|
||||
import Http
|
||||
import Todo.UUID as UUID exposing (UUID)
|
||||
import Url.Builder exposing (QueryParameter)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
{-| Http.request, except it takes an Endpoint instead of a Url.
|
||||
-}
|
||||
request :
|
||||
{ body : Http.Body
|
||||
, expect : Http.Expect a
|
||||
, headers : List Http.Header
|
||||
, method : String
|
||||
, timeout : Maybe Float
|
||||
, url : Endpoint
|
||||
, tracker : Maybe String
|
||||
}
|
||||
-> Cmd a
|
||||
request config =
|
||||
Http.request
|
||||
{ body = config.body
|
||||
, expect = config.expect
|
||||
, headers = config.headers
|
||||
, method = config.method
|
||||
, timeout = config.timeout
|
||||
, url = unwrap config.url
|
||||
, tracker = config.tracker
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
{-| Get a URL to the Conduit API.
|
||||
This is not publicly exposed, because we want to make sure the only way to get one of these URLs is from this module.
|
||||
-}
|
||||
type Endpoint
|
||||
= Endpoint String
|
||||
|
||||
|
||||
unwrap : Endpoint -> String
|
||||
unwrap (Endpoint str) =
|
||||
str
|
||||
|
||||
|
||||
url : List String -> List QueryParameter -> Endpoint
|
||||
url paths queryParams =
|
||||
-- NOTE: Url.Builder takes care of percent-encoding special URL characters.
|
||||
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
|
||||
Url.Builder.crossOrigin "https://conduit.productionready.io"
|
||||
("api" :: paths)
|
||||
queryParams
|
||||
|> Endpoint
|
||||
|
||||
|
||||
|
||||
-- ENDPOINTS
|
||||
|
||||
|
||||
login : Endpoint
|
||||
login =
|
||||
url [ "users", "login" ] []
|
||||
|
||||
|
||||
user : Endpoint
|
||||
user =
|
||||
url [ "user" ] []
|
||||
|
||||
|
||||
users : Endpoint
|
||||
users =
|
||||
url [ "users" ] []
|
||||
|
||||
|
||||
follow : Username -> Endpoint
|
||||
follow uname =
|
||||
url [ "profiles", Username.toString uname, "follow" ] []
|
||||
|
||||
|
||||
|
||||
-- ARTICLE ENDPOINTS
|
||||
|
||||
|
||||
todo : UUID -> Endpoint
|
||||
todo uuid =
|
||||
url [ "articles", UUID.toString uuid ] []
|
||||
|
||||
|
||||
todoList : List QueryParameter -> Endpoint
|
||||
todoList params =
|
||||
url [ "articles" ] params
|
||||
|
||||
|
||||
tags : Endpoint
|
||||
tags =
|
||||
url [ "tags" ] []
|
||||
module Api.Endpoint exposing (Endpoint, login, request, tags, todo, todoList, user, users)
|
||||
|
||||
import Http
|
||||
import Todo.UUID as UUID exposing (UUID)
|
||||
import Url.Builder exposing (QueryParameter)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
{-| Http.request, except it takes an Endpoint instead of a Url.
|
||||
-}
|
||||
request :
|
||||
{ body : Http.Body
|
||||
, expect : Http.Expect a
|
||||
, headers : List Http.Header
|
||||
, method : String
|
||||
, timeout : Maybe Float
|
||||
, url : Endpoint
|
||||
, tracker : Maybe String
|
||||
}
|
||||
-> Cmd a
|
||||
request config =
|
||||
Http.request
|
||||
{ body = config.body
|
||||
, expect = config.expect
|
||||
, headers = config.headers
|
||||
, method = config.method
|
||||
, timeout = config.timeout
|
||||
, url = unwrap config.url
|
||||
, tracker = config.tracker
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
{-| Get a URL to the Conduit API.
|
||||
This is not publicly exposed, because we want to make sure the only way to get one of these URLs is from this module.
|
||||
-}
|
||||
type Endpoint
|
||||
= Endpoint String
|
||||
|
||||
|
||||
unwrap : Endpoint -> String
|
||||
unwrap (Endpoint str) =
|
||||
str
|
||||
|
||||
|
||||
url : List String -> List QueryParameter -> Endpoint
|
||||
url paths queryParams =
|
||||
-- NOTE: Url.Builder takes care of percent-encoding special URL characters.
|
||||
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
|
||||
Url.Builder.crossOrigin "https://conduit.productionready.io"
|
||||
("api" :: paths)
|
||||
queryParams
|
||||
|> Endpoint
|
||||
|
||||
|
||||
|
||||
-- ENDPOINTS
|
||||
|
||||
|
||||
login : Endpoint
|
||||
login =
|
||||
url [ "users", "login" ] []
|
||||
|
||||
|
||||
user : Endpoint
|
||||
user =
|
||||
url [ "user" ] []
|
||||
|
||||
|
||||
users : Endpoint
|
||||
users =
|
||||
url [ "users" ] []
|
||||
|
||||
|
||||
follow : Username -> Endpoint
|
||||
follow uname =
|
||||
url [ "profiles", Username.toString uname, "follow" ] []
|
||||
|
||||
|
||||
|
||||
-- ARTICLE ENDPOINTS
|
||||
|
||||
|
||||
todo : UUID -> Endpoint
|
||||
todo uuid =
|
||||
url [ "articles", UUID.toString uuid ] []
|
||||
|
||||
|
||||
todoList : List QueryParameter -> Endpoint
|
||||
todoList params =
|
||||
url [ "articles" ] params
|
||||
|
||||
|
||||
tags : Endpoint
|
||||
tags =
|
||||
url [ "tags" ] []
|
||||
|
|
|
@ -1,46 +1,46 @@
|
|||
module Asset exposing (Image, defaultAvatar, error, loading, src)
|
||||
|
||||
{-| Assets, such as images, videos, and audio. (We only have images for now.)
|
||||
We should never expose asset URLs directly; this module should be in charge of
|
||||
all of them. One source of truth!
|
||||
-}
|
||||
|
||||
import Html exposing (Attribute, Html)
|
||||
import Html.Attributes as Attr
|
||||
|
||||
|
||||
type Image
|
||||
= Image String
|
||||
|
||||
|
||||
|
||||
-- IMAGES
|
||||
|
||||
|
||||
error : Image
|
||||
error =
|
||||
image "error.jpg"
|
||||
|
||||
|
||||
loading : Image
|
||||
loading =
|
||||
image "loading.svg"
|
||||
|
||||
|
||||
defaultAvatar : Image
|
||||
defaultAvatar =
|
||||
image "smiley-cyrus.jpg"
|
||||
|
||||
|
||||
image : String -> Image
|
||||
image filename =
|
||||
Image ("/assets/images/" ++ filename)
|
||||
|
||||
|
||||
|
||||
-- USING IMAGES
|
||||
|
||||
|
||||
src : Image -> Attribute msg
|
||||
src (Image url) =
|
||||
Attr.src url
|
||||
module Asset exposing (Image, defaultAvatar, error, loading, src)
|
||||
|
||||
{-| Assets, such as images, videos, and audio. (We only have images for now.)
|
||||
We should never expose asset URLs directly; this module should be in charge of
|
||||
all of them. One source of truth!
|
||||
-}
|
||||
|
||||
import Html exposing (Attribute, Html)
|
||||
import Html.Attributes as Attr
|
||||
|
||||
|
||||
type Image
|
||||
= Image String
|
||||
|
||||
|
||||
|
||||
-- IMAGES
|
||||
|
||||
|
||||
error : Image
|
||||
error =
|
||||
image "error.jpg"
|
||||
|
||||
|
||||
loading : Image
|
||||
loading =
|
||||
image "loading.svg"
|
||||
|
||||
|
||||
defaultAvatar : Image
|
||||
defaultAvatar =
|
||||
image "smiley-cyrus.jpg"
|
||||
|
||||
|
||||
image : String -> Image
|
||||
image filename =
|
||||
Image ("/assets/images/" ++ filename)
|
||||
|
||||
|
||||
|
||||
-- USING IMAGES
|
||||
|
||||
|
||||
src : Image -> Attribute msg
|
||||
src (Image url) =
|
||||
Attr.src url
|
||||
|
|
|
@ -1,56 +1,56 @@
|
|||
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
|
||||
|
||||
import Asset
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Avatar
|
||||
= Avatar (Maybe String)
|
||||
|
||||
|
||||
|
||||
-- CREATE
|
||||
|
||||
|
||||
decoder : Decoder Avatar
|
||||
decoder =
|
||||
Decode.map Avatar (Decode.nullable Decode.string)
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
encode : Avatar -> Value
|
||||
encode (Avatar maybeUrl) =
|
||||
case maybeUrl of
|
||||
Just url ->
|
||||
Encode.string url
|
||||
|
||||
Nothing ->
|
||||
Encode.null
|
||||
|
||||
|
||||
src : Avatar -> Attribute msg
|
||||
src (Avatar maybeUrl) =
|
||||
case maybeUrl of
|
||||
Nothing ->
|
||||
Asset.src Asset.defaultAvatar
|
||||
|
||||
Just "" ->
|
||||
Asset.src Asset.defaultAvatar
|
||||
|
||||
Just url ->
|
||||
Html.Attributes.src url
|
||||
|
||||
|
||||
toMaybeString : Avatar -> Maybe String
|
||||
toMaybeString (Avatar maybeUrl) =
|
||||
maybeUrl
|
||||
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
|
||||
|
||||
import Asset
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Avatar
|
||||
= Avatar (Maybe String)
|
||||
|
||||
|
||||
|
||||
-- CREATE
|
||||
|
||||
|
||||
decoder : Decoder Avatar
|
||||
decoder =
|
||||
Decode.map Avatar (Decode.nullable Decode.string)
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
encode : Avatar -> Value
|
||||
encode (Avatar maybeUrl) =
|
||||
case maybeUrl of
|
||||
Just url ->
|
||||
Encode.string url
|
||||
|
||||
Nothing ->
|
||||
Encode.null
|
||||
|
||||
|
||||
src : Avatar -> Attribute msg
|
||||
src (Avatar maybeUrl) =
|
||||
case maybeUrl of
|
||||
Nothing ->
|
||||
Asset.src Asset.defaultAvatar
|
||||
|
||||
Just "" ->
|
||||
Asset.src Asset.defaultAvatar
|
||||
|
||||
Just url ->
|
||||
Html.Attributes.src url
|
||||
|
||||
|
||||
toMaybeString : Avatar -> Maybe String
|
||||
toMaybeString (Avatar maybeUrl) =
|
||||
maybeUrl
|
||||
|
|
|
@ -1,38 +1,38 @@
|
|||
module Email exposing (Email, decoder, encode, toString)
|
||||
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
|
||||
|
||||
{-| An email address.
|
||||
Having this as a custom type that's separate from String makes certain
|
||||
mistakes impossible. Consider this function:
|
||||
updateEmailAddress : Email -> String -> Http.Request
|
||||
updateEmailAddress email password = ...
|
||||
(The server needs your password to confirm that you should be allowed
|
||||
to update the email address.)
|
||||
Because Email is not a type alias for String, but is instead a separate
|
||||
custom type, it is now impossible to mix up the argument order of the
|
||||
email and the password. If we do, it won't compile!
|
||||
If Email were instead defined as `type alias Email = String`, we could
|
||||
call updateEmailAddress password email and it would compile (and never
|
||||
work properly).
|
||||
This way, we make it impossible for a bug like that to compile!
|
||||
-}
|
||||
type Email
|
||||
= Email String
|
||||
|
||||
|
||||
toString : Email -> String
|
||||
toString (Email str) =
|
||||
str
|
||||
|
||||
|
||||
encode : Email -> Value
|
||||
encode (Email str) =
|
||||
Encode.string str
|
||||
|
||||
|
||||
decoder : Decoder Email
|
||||
decoder =
|
||||
Decode.map Email Decode.string
|
||||
module Email exposing (Email, decoder, encode, toString)
|
||||
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
|
||||
|
||||
{-| An email address.
|
||||
Having this as a custom type that's separate from String makes certain
|
||||
mistakes impossible. Consider this function:
|
||||
updateEmailAddress : Email -> String -> Http.Request
|
||||
updateEmailAddress email password = ...
|
||||
(The server needs your password to confirm that you should be allowed
|
||||
to update the email address.)
|
||||
Because Email is not a type alias for String, but is instead a separate
|
||||
custom type, it is now impossible to mix up the argument order of the
|
||||
email and the password. If we do, it won't compile!
|
||||
If Email were instead defined as `type alias Email = String`, we could
|
||||
call updateEmailAddress password email and it would compile (and never
|
||||
work properly).
|
||||
This way, we make it impossible for a bug like that to compile!
|
||||
-}
|
||||
type Email
|
||||
= Email String
|
||||
|
||||
|
||||
toString : Email -> String
|
||||
toString (Email str) =
|
||||
str
|
||||
|
||||
|
||||
encode : Email -> Value
|
||||
encode (Email str) =
|
||||
Encode.string str
|
||||
|
||||
|
||||
decoder : Decoder Email
|
||||
decoder =
|
||||
Decode.map Email Decode.string
|
||||
|
|
|
@ -1,57 +1,57 @@
|
|||
module NavRow exposing (..)
|
||||
|
||||
import CommonElements exposing (..)
|
||||
import Element exposing (..)
|
||||
import Element.Region as Region
|
||||
import Model exposing (..)
|
||||
import Request exposing (..)
|
||||
import Url
|
||||
|
||||
|
||||
getNavRow : Model -> Element Msg
|
||||
getNavRow model =
|
||||
row
|
||||
[ Region.navigation
|
||||
|
||||
--, explain Debug.todo
|
||||
, paddingXY 10 5
|
||||
, spacing 10
|
||||
, width fill
|
||||
]
|
||||
[ namedLink "/" "TODOAPP"
|
||||
, getDebugInfo model
|
||||
, getCurrentUser model
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- temp function to get current page url
|
||||
-- and links in case shit breaks
|
||||
|
||||
|
||||
getDebugInfo : Model -> Element Msg
|
||||
getDebugInfo model =
|
||||
row
|
||||
[ centerX ]
|
||||
[ text "Current URL: "
|
||||
, bolded (Url.toString model.url)
|
||||
, column []
|
||||
[ namedLink "/" "root"
|
||||
, namedLink "/login" "login"
|
||||
, namedLink "/signup" "signup"
|
||||
, namedLink "/account" "account"
|
||||
, namedLink "/about" "about"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
getCurrentUser : Model -> Element Msg
|
||||
getCurrentUser model =
|
||||
el []
|
||||
(case model.user of
|
||||
Just user ->
|
||||
namedLink "/account" user.email
|
||||
|
||||
_ ->
|
||||
namedLink "/login" "Log In"
|
||||
)
|
||||
module NavRow exposing (..)
|
||||
|
||||
import CommonElements exposing (..)
|
||||
import Element exposing (..)
|
||||
import Element.Region as Region
|
||||
import Model exposing (..)
|
||||
import Request exposing (..)
|
||||
import Url
|
||||
|
||||
|
||||
getNavRow : Model -> Element Msg
|
||||
getNavRow model =
|
||||
row
|
||||
[ Region.navigation
|
||||
|
||||
--, explain Debug.todo
|
||||
, paddingXY 10 5
|
||||
, spacing 10
|
||||
, width fill
|
||||
]
|
||||
[ namedLink "/" "TODOAPP"
|
||||
, getDebugInfo model
|
||||
, getCurrentUser model
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- temp function to get current page url
|
||||
-- and links in case shit breaks
|
||||
|
||||
|
||||
getDebugInfo : Model -> Element Msg
|
||||
getDebugInfo model =
|
||||
row
|
||||
[ centerX ]
|
||||
[ text "Current URL: "
|
||||
, bolded (Url.toString model.url)
|
||||
, column []
|
||||
[ namedLink "/" "root"
|
||||
, namedLink "/login" "login"
|
||||
, namedLink "/signup" "signup"
|
||||
, namedLink "/account" "account"
|
||||
, namedLink "/about" "about"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
getCurrentUser : Model -> Element Msg
|
||||
getCurrentUser model =
|
||||
el []
|
||||
(case model.user of
|
||||
Just user ->
|
||||
namedLink "/account" user.email
|
||||
|
||||
_ ->
|
||||
namedLink "/login" "Log In"
|
||||
)
|
||||
|
|
|
@ -1,66 +1,66 @@
|
|||
module PageState exposing (getMainContent)
|
||||
|
||||
import About as AboutPage
|
||||
import Account as AccountPage
|
||||
import Element exposing (..)
|
||||
import Home as HomePage
|
||||
import Login as LoginPage
|
||||
import Model exposing (..)
|
||||
import Signup as SignupPage
|
||||
import Url
|
||||
import Url.Parser as Parser exposing (..)
|
||||
|
||||
|
||||
type Route
|
||||
= About
|
||||
| Account
|
||||
| Home
|
||||
| Login
|
||||
| Signup
|
||||
| NotFound
|
||||
|
||||
|
||||
getMainContent : Model -> Element Msg
|
||||
getMainContent model =
|
||||
el []
|
||||
(case consume (toRoute model.url) of
|
||||
About ->
|
||||
AboutPage.getPage model
|
||||
|
||||
Account ->
|
||||
AccountPage.getPage model
|
||||
|
||||
Home ->
|
||||
HomePage.getPage model
|
||||
|
||||
Login ->
|
||||
LoginPage.getPage model
|
||||
|
||||
Signup ->
|
||||
SignupPage.getPage model
|
||||
|
||||
_ ->
|
||||
text "Page not found."
|
||||
)
|
||||
|
||||
|
||||
routeParser : Parser (Route -> a) a
|
||||
routeParser =
|
||||
oneOf
|
||||
[ Parser.map Home top
|
||||
, Parser.map Account (s "account")
|
||||
, Parser.map About (s "about")
|
||||
, Parser.map Login (s "login")
|
||||
, Parser.map Signup (s "signup")
|
||||
]
|
||||
|
||||
|
||||
toRoute : Url.Url -> Maybe Route
|
||||
toRoute url =
|
||||
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||
|> Parser.parse routeParser
|
||||
|
||||
|
||||
consume : Maybe Route -> Route
|
||||
consume route =
|
||||
Maybe.withDefault NotFound route
|
||||
module PageState exposing (getMainContent)
|
||||
|
||||
import About as AboutPage
|
||||
import Account as AccountPage
|
||||
import Element exposing (..)
|
||||
import Home as HomePage
|
||||
import Login as LoginPage
|
||||
import Model exposing (..)
|
||||
import Signup as SignupPage
|
||||
import Url
|
||||
import Url.Parser as Parser exposing (..)
|
||||
|
||||
|
||||
type Route
|
||||
= About
|
||||
| Account
|
||||
| Home
|
||||
| Login
|
||||
| Signup
|
||||
| NotFound
|
||||
|
||||
|
||||
getMainContent : Model -> Element Msg
|
||||
getMainContent model =
|
||||
el []
|
||||
(case consume (toRoute model.url) of
|
||||
About ->
|
||||
AboutPage.getPage model
|
||||
|
||||
Account ->
|
||||
AccountPage.getPage model
|
||||
|
||||
Home ->
|
||||
HomePage.getPage model
|
||||
|
||||
Login ->
|
||||
LoginPage.getPage model
|
||||
|
||||
Signup ->
|
||||
SignupPage.getPage model
|
||||
|
||||
_ ->
|
||||
text "Page not found."
|
||||
)
|
||||
|
||||
|
||||
routeParser : Parser (Route -> a) a
|
||||
routeParser =
|
||||
oneOf
|
||||
[ Parser.map Home top
|
||||
, Parser.map Account (s "account")
|
||||
, Parser.map About (s "about")
|
||||
, Parser.map Login (s "login")
|
||||
, Parser.map Signup (s "signup")
|
||||
]
|
||||
|
||||
|
||||
toRoute : Url.Url -> Maybe Route
|
||||
toRoute url =
|
||||
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||
|> Parser.parse routeParser
|
||||
|
||||
|
||||
consume : Maybe Route -> Route
|
||||
consume route =
|
||||
Maybe.withDefault NotFound route
|
||||
|
|
|
@ -1,98 +1,98 @@
|
|||
module Route exposing (Route(..), fromUrl, href, replaceUrl)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes as Attr
|
||||
import Todo.UUID as UUID
|
||||
import Url exposing (Url)
|
||||
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
|
||||
-- ROUTING
|
||||
|
||||
|
||||
type Route
|
||||
= Home
|
||||
| Login
|
||||
| Logout
|
||||
| Signup
|
||||
| Account
|
||||
| Todo UUID.UUID
|
||||
| NewTodo
|
||||
| EditTodo UUID.UUID
|
||||
|
||||
|
||||
parser : Parser (Route -> a) a
|
||||
parser =
|
||||
oneOf
|
||||
[ Parser.map Home Parser.top
|
||||
, Parser.map Login (s "login")
|
||||
, Parser.map Logout (s "logout")
|
||||
, Parser.map Account (s "account")
|
||||
, Parser.map Signup (s "signup")
|
||||
, Parser.map Todo (s "article" </> UUID.urlParser)
|
||||
, Parser.map NewTodo (s "editor")
|
||||
, Parser.map EditTodo (s "editor" </> UUID.urlParser)
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- PUBLIC HELPERS
|
||||
|
||||
|
||||
href : Route -> Attribute msg
|
||||
href targetRoute =
|
||||
Attr.href (routeToString targetRoute)
|
||||
|
||||
|
||||
replaceUrl : Nav.Key -> Route -> Cmd msg
|
||||
replaceUrl key route =
|
||||
Nav.replaceUrl key (routeToString route)
|
||||
|
||||
|
||||
fromUrl : Url -> Maybe Route
|
||||
fromUrl url =
|
||||
-- The RealWorld spec treats the fragment like a path.
|
||||
-- This makes it *literally* the path, so we can proceed
|
||||
-- with parsing as if it had been a normal path all along.
|
||||
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||
|> Parser.parse parser
|
||||
|
||||
|
||||
|
||||
-- INTERNAL
|
||||
|
||||
|
||||
routeToString : Route -> String
|
||||
routeToString page =
|
||||
"#/" ++ String.join "/" (routeToPieces page)
|
||||
|
||||
|
||||
routeToPieces : Route -> List String
|
||||
routeToPieces page =
|
||||
case page of
|
||||
Home ->
|
||||
[]
|
||||
|
||||
Login ->
|
||||
[ "login" ]
|
||||
|
||||
Logout ->
|
||||
[ "logout" ]
|
||||
|
||||
Signup ->
|
||||
[ "Signup" ]
|
||||
|
||||
Account ->
|
||||
[ "account" ]
|
||||
|
||||
Todo uuid ->
|
||||
[ "article", UUID.toString uuid ]
|
||||
|
||||
NewTodo ->
|
||||
[ "editor" ]
|
||||
|
||||
EditTodo uuid ->
|
||||
[ "editor", UUID.toString uuid ]
|
||||
module Route exposing (Route(..), fromUrl, href, replaceUrl)
|
||||
|
||||
import Browser.Navigation as Nav
|
||||
import Html exposing (Attribute)
|
||||
import Html.Attributes as Attr
|
||||
import Todo.UUID as UUID
|
||||
import Url exposing (Url)
|
||||
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
|
||||
-- ROUTING
|
||||
|
||||
|
||||
type Route
|
||||
= Home
|
||||
| Login
|
||||
| Logout
|
||||
| Signup
|
||||
| Account
|
||||
| Todo UUID.UUID
|
||||
| NewTodo
|
||||
| EditTodo UUID.UUID
|
||||
|
||||
|
||||
parser : Parser (Route -> a) a
|
||||
parser =
|
||||
oneOf
|
||||
[ Parser.map Home Parser.top
|
||||
, Parser.map Login (s "login")
|
||||
, Parser.map Logout (s "logout")
|
||||
, Parser.map Account (s "account")
|
||||
, Parser.map Signup (s "signup")
|
||||
, Parser.map Todo (s "article" </> UUID.urlParser)
|
||||
, Parser.map NewTodo (s "editor")
|
||||
, Parser.map EditTodo (s "editor" </> UUID.urlParser)
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- PUBLIC HELPERS
|
||||
|
||||
|
||||
href : Route -> Attribute msg
|
||||
href targetRoute =
|
||||
Attr.href (routeToString targetRoute)
|
||||
|
||||
|
||||
replaceUrl : Nav.Key -> Route -> Cmd msg
|
||||
replaceUrl key route =
|
||||
Nav.replaceUrl key (routeToString route)
|
||||
|
||||
|
||||
fromUrl : Url -> Maybe Route
|
||||
fromUrl url =
|
||||
-- The RealWorld spec treats the fragment like a path.
|
||||
-- This makes it *literally* the path, so we can proceed
|
||||
-- with parsing as if it had been a normal path all along.
|
||||
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||
|> Parser.parse parser
|
||||
|
||||
|
||||
|
||||
-- INTERNAL
|
||||
|
||||
|
||||
routeToString : Route -> String
|
||||
routeToString page =
|
||||
"#/" ++ String.join "/" (routeToPieces page)
|
||||
|
||||
|
||||
routeToPieces : Route -> List String
|
||||
routeToPieces page =
|
||||
case page of
|
||||
Home ->
|
||||
[]
|
||||
|
||||
Login ->
|
||||
[ "login" ]
|
||||
|
||||
Logout ->
|
||||
[ "logout" ]
|
||||
|
||||
Signup ->
|
||||
[ "Signup" ]
|
||||
|
||||
Account ->
|
||||
[ "account" ]
|
||||
|
||||
Todo uuid ->
|
||||
[ "article", UUID.toString uuid ]
|
||||
|
||||
NewTodo ->
|
||||
[ "editor" ]
|
||||
|
||||
EditTodo uuid ->
|
||||
[ "editor", UUID.toString uuid ]
|
||||
|
|
|
@ -1,75 +1,75 @@
|
|||
module Session exposing (Session, changes, cred, fromViewer, navKey, viewer)
|
||||
|
||||
import Api exposing (Cred)
|
||||
import Avatar exposing (Avatar)
|
||||
import Browser.Navigation as Nav
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
import Profile exposing (Profile)
|
||||
import Time
|
||||
import Viewer exposing (Viewer)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Session
|
||||
= LoggedIn Nav.Key Viewer
|
||||
| Guest Nav.Key
|
||||
|
||||
|
||||
|
||||
-- INFO
|
||||
|
||||
|
||||
viewer : Session -> Maybe Viewer
|
||||
viewer session =
|
||||
case session of
|
||||
LoggedIn _ val ->
|
||||
Just val
|
||||
|
||||
Guest _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
cred : Session -> Maybe Cred
|
||||
cred session =
|
||||
case session of
|
||||
LoggedIn _ val ->
|
||||
Just (Viewer.cred val)
|
||||
|
||||
Guest _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
navKey : Session -> Nav.Key
|
||||
navKey session =
|
||||
case session of
|
||||
LoggedIn key _ ->
|
||||
key
|
||||
|
||||
Guest key ->
|
||||
key
|
||||
|
||||
|
||||
|
||||
-- CHANGES
|
||||
|
||||
|
||||
changes : (Session -> msg) -> Nav.Key -> Sub msg
|
||||
changes toMsg key =
|
||||
Api.viewerChanges (\maybeViewer -> toMsg (fromViewer key maybeViewer)) Viewer.decoder
|
||||
|
||||
|
||||
fromViewer : Nav.Key -> Maybe Viewer -> Session
|
||||
fromViewer key maybeViewer =
|
||||
-- It's stored in localStorage as a JSON String;
|
||||
-- first decode the Value as a String, then
|
||||
-- decode that String as JSON.
|
||||
case maybeViewer of
|
||||
Just viewerVal ->
|
||||
LoggedIn key viewerVal
|
||||
|
||||
Nothing ->
|
||||
Guest key
|
||||
module Session exposing (Session, changes, cred, fromViewer, navKey, viewer)
|
||||
|
||||
import Api exposing (Cred)
|
||||
import Avatar exposing (Avatar)
|
||||
import Browser.Navigation as Nav
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
import Profile exposing (Profile)
|
||||
import Time
|
||||
import Viewer exposing (Viewer)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Session
|
||||
= LoggedIn Nav.Key Viewer
|
||||
| Guest Nav.Key
|
||||
|
||||
|
||||
|
||||
-- INFO
|
||||
|
||||
|
||||
viewer : Session -> Maybe Viewer
|
||||
viewer session =
|
||||
case session of
|
||||
LoggedIn _ val ->
|
||||
Just val
|
||||
|
||||
Guest _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
cred : Session -> Maybe Cred
|
||||
cred session =
|
||||
case session of
|
||||
LoggedIn _ val ->
|
||||
Just (Viewer.cred val)
|
||||
|
||||
Guest _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
navKey : Session -> Nav.Key
|
||||
navKey session =
|
||||
case session of
|
||||
LoggedIn key _ ->
|
||||
key
|
||||
|
||||
Guest key ->
|
||||
key
|
||||
|
||||
|
||||
|
||||
-- CHANGES
|
||||
|
||||
|
||||
changes : (Session -> msg) -> Nav.Key -> Sub msg
|
||||
changes toMsg key =
|
||||
Api.viewerChanges (\maybeViewer -> toMsg (fromViewer key maybeViewer)) Viewer.decoder
|
||||
|
||||
|
||||
fromViewer : Nav.Key -> Maybe Viewer -> Session
|
||||
fromViewer key maybeViewer =
|
||||
-- It's stored in localStorage as a JSON String;
|
||||
-- first decode the Value as a String, then
|
||||
-- decode that String as JSON.
|
||||
case maybeViewer of
|
||||
Just viewerVal ->
|
||||
LoggedIn key viewerVal
|
||||
|
||||
Nothing ->
|
||||
Guest key
|
||||
|
|
|
@ -1,253 +1,253 @@
|
|||
module Todo exposing (Full, Preview, Todo, author, body, favorite, favoriteButton, fetch, fromPreview, fullDecoder, mapAuthor, metadata, previewDecoder, unfavorite, unfavoriteButton, uuid)
|
||||
|
||||
{-| The interface to the Todo data structure.
|
||||
This includes:
|
||||
|
||||
- The Todo type itself
|
||||
- Ways to make HTTP requests to retrieve and modify Todos
|
||||
- Ways to access information about an Todo
|
||||
- Converting between various types
|
||||
|
||||
-}
|
||||
|
||||
import Api exposing (Cred)
|
||||
import Api.Endpoint as Endpoint
|
||||
import Author exposing (Author)
|
||||
import Element exposing (..)
|
||||
import Http
|
||||
import Iso8601
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode
|
||||
import Markdown
|
||||
import Time
|
||||
import Todo.Body as Body exposing (Body)
|
||||
import Todo.Tag as Tag exposing (Tag)
|
||||
import Todo.UUID exposing (UUID)
|
||||
import Username as Username exposing (Username)
|
||||
import Viewer exposing (Viewer)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
{-| An Todo, optionally with an Todo body.
|
||||
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
|
||||
consider the difference between the "view individual Todo" page (which
|
||||
renders one Todo, including its body) and the "Todo feed" -
|
||||
which displays multiple Todos, but without bodies.
|
||||
This definition for `Todo` means we can write:
|
||||
viewTodo : Todo Full -> Html msg
|
||||
viewFeed : List (Todo Preview) -> Html msg
|
||||
This indicates that `viewTodo` requires an Todo _with a `body` present_,
|
||||
wereas `viewFeed` accepts Todos with no bodies. (We could also have written
|
||||
it as `List (Todo a)` to specify that feeds can accept either Todos that
|
||||
have `body` present or not. Either work, given that feeds do not attempt to
|
||||
read the `body` field from Todos.)
|
||||
This is an important distinction, because in Request.Todo, the `feed`
|
||||
function produces `List (Todo Preview)` because the API does not return bodies.
|
||||
Those Todos are useful to the feed, but not to the individual Todo view.
|
||||
-}
|
||||
type Todo a
|
||||
= Todo Internals a
|
||||
|
||||
|
||||
{-| Metadata about the Todo - its title, description, and so on.
|
||||
Importantly, this module's public API exposes a way to read this metadata, but
|
||||
not to alter it. This is read-only information!
|
||||
If we find ourselves using any particular piece of metadata often,
|
||||
for example `title`, we could expose a convenience function like this:
|
||||
Todo.title : Todo a -> String
|
||||
If you like, it's totally reasonable to expose a function like that for every one
|
||||
of these fields!
|
||||
(Okay, to be completely honest, exposing one function per field is how I prefer
|
||||
to do it, and that's how I originally wrote this module. However, I'm aware that
|
||||
this code base has become a common reference point for beginners, and I think it
|
||||
is _extremely important_ that slapping some "getters and setters" on a record
|
||||
does not become a habit for anyone who is getting started with Elm. The whole
|
||||
point of making the Todo type opaque is to create guarantees through
|
||||
_selectively choosing boundaries_ around it. If you aren't selective about
|
||||
where those boundaries are, and instead expose a "getter and setter" for every
|
||||
field in the record, the result is an API with no more guarantees than if you'd
|
||||
exposed the entire record directly! It is so important to me that beginners not
|
||||
fall into the terrible "getters and setters" trap that I've exposed this
|
||||
Metadata record instead of exposing a single function for each of its fields,
|
||||
as I did originally. This record is not a bad way to do it, by any means,
|
||||
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
|
||||
)
|
||||
-}
|
||||
type alias Metadata =
|
||||
{ description : String
|
||||
, title : String
|
||||
, tags : List String
|
||||
, createdAt : Time.Posix
|
||||
, favorited : Bool
|
||||
, favoritesCount : Int
|
||||
}
|
||||
|
||||
|
||||
type alias Internals =
|
||||
{ uuid : UUID
|
||||
, author : Author
|
||||
, metadata : Metadata
|
||||
}
|
||||
|
||||
|
||||
type Preview
|
||||
= Preview
|
||||
|
||||
|
||||
type Full
|
||||
= Full Body
|
||||
|
||||
|
||||
|
||||
-- INFO
|
||||
|
||||
|
||||
author : Todo a -> Author
|
||||
author (Todo internals _) =
|
||||
internals.author
|
||||
|
||||
|
||||
metadata : Todo a -> Metadata
|
||||
metadata (Todo internals _) =
|
||||
internals.metadata
|
||||
|
||||
|
||||
uuid : Todo a -> UUID
|
||||
uuid (Todo internals _) =
|
||||
internals.uuid
|
||||
|
||||
|
||||
body : Todo Full -> Body
|
||||
body (Todo _ (Full extraInfo)) =
|
||||
extraInfo
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
{-| This is the only way you can transform an existing Todo:
|
||||
you can change its author (e.g. to follow or unfollow them).
|
||||
All other Todo data necessarily comes from the server!
|
||||
We can tell this for sure by looking at the types of the exposed functions
|
||||
in this module.
|
||||
-}
|
||||
mapAuthor : (Author -> Author) -> Todo a -> Todo a
|
||||
mapAuthor transform (Todo info extras) =
|
||||
Todo { info | author = transform info.author } extras
|
||||
|
||||
|
||||
fromPreview : Body -> Todo Preview -> Todo Full
|
||||
fromPreview newBody (Todo info Preview) =
|
||||
Todo info (Full newBody)
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
|
||||
|
||||
previewDecoder : Maybe Cred -> Decoder (Todo Preview)
|
||||
previewDecoder maybeCred =
|
||||
Decode.succeed Todo
|
||||
|> custom (internalsDecoder maybeCred)
|
||||
|> hardcoded Preview
|
||||
|
||||
|
||||
fullDecoder : Maybe Cred -> Decoder (Todo Full)
|
||||
fullDecoder maybeCred =
|
||||
Decode.succeed Todo
|
||||
|> custom (internalsDecoder maybeCred)
|
||||
|> required "body" (Decode.map Full Body.decoder)
|
||||
|
||||
|
||||
internalsDecoder : Maybe Cred -> Decoder Internals
|
||||
internalsDecoder maybeCred =
|
||||
Decode.succeed Internals
|
||||
|> required "uuid" UUID.decoder
|
||||
|> required "author" (Author.decoder maybeCred)
|
||||
|> custom metadataDecoder
|
||||
|
||||
|
||||
metadataDecoder : Decoder Metadata
|
||||
metadataDecoder =
|
||||
Decode.succeed Metadata
|
||||
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|
||||
|> required "title" Decode.string
|
||||
|> required "tagList" (Decode.list Decode.string)
|
||||
|> required "createdAt" Iso8601.decoder
|
||||
|> required "favorited" Decode.bool
|
||||
|> required "favoritesCount" Decode.int
|
||||
|
||||
|
||||
|
||||
-- SINGLE
|
||||
|
||||
|
||||
fetch : Maybe Cred -> UUID -> Http.Request (Todo Full)
|
||||
fetch maybeCred uuid =
|
||||
Decode.field "Todo" (fullDecoder maybeCred)
|
||||
|> Api.get (Endpoint.Todo uuid) maybeCred
|
||||
|
||||
|
||||
|
||||
-- FAVORITE
|
||||
|
||||
|
||||
favorite : UUID -> Cred -> Http.Request (Todo Preview)
|
||||
favorite uuid cred =
|
||||
Api.post (Endpoint.favorite uuid) (Just cred) Http.emptyBody (faveDecoder cred)
|
||||
|
||||
|
||||
unfavorite : UUID -> Cred -> Http.Request (Todo Preview)
|
||||
unfavorite uuid cred =
|
||||
Api.delete (Endpoint.favorite uuid) cred Http.emptyBody (faveDecoder cred)
|
||||
|
||||
|
||||
faveDecoder : Cred -> Decoder (Todo Preview)
|
||||
faveDecoder cred =
|
||||
Decode.field "Todo" (previewDecoder (Just cred))
|
||||
|
||||
|
||||
{-| This is a "build your own element" API.
|
||||
You pass it some configuration, followed by a `List (Attribute msg)` and a
|
||||
`List (Html msg)`, just like any standard Html element.
|
||||
-}
|
||||
favoriteButton :
|
||||
Cred
|
||||
-> msg
|
||||
-> List (Attribute msg)
|
||||
-> List (Element msg)
|
||||
-> Element msg
|
||||
favoriteButton _ msg attrs kids =
|
||||
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
|
||||
|
||||
|
||||
unfavoriteButton :
|
||||
Cred
|
||||
-> msg
|
||||
-> List (Attribute msg)
|
||||
-> List (Element msg)
|
||||
-> Element msg
|
||||
unfavoriteButton _ msg attrs kids =
|
||||
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
|
||||
|
||||
|
||||
toggleFavoriteButton :
|
||||
String
|
||||
-> msg
|
||||
-> List (Attribute msg)
|
||||
-> List (Element msg)
|
||||
-> Element msg
|
||||
toggleFavoriteButton classStr msg attrs kids =
|
||||
Html.button
|
||||
(class classStr :: onClickStopPropagation msg :: attrs)
|
||||
(i [ class "ion-heart" ] [] :: kids)
|
||||
|
||||
|
||||
onClickStopPropagation : msg -> Attribute msg
|
||||
onClickStopPropagation msg =
|
||||
stopPropagationOn "click"
|
||||
(Decode.succeed ( msg, True ))
|
||||
module Todo exposing (Full, Preview, Todo, author, body, favorite, favoriteButton, fetch, fromPreview, fullDecoder, mapAuthor, metadata, previewDecoder, unfavorite, unfavoriteButton, uuid)
|
||||
|
||||
{-| The interface to the Todo data structure.
|
||||
This includes:
|
||||
|
||||
- The Todo type itself
|
||||
- Ways to make HTTP requests to retrieve and modify Todos
|
||||
- Ways to access information about an Todo
|
||||
- Converting between various types
|
||||
|
||||
-}
|
||||
|
||||
import Api exposing (Cred)
|
||||
import Api.Endpoint as Endpoint
|
||||
import Author exposing (Author)
|
||||
import Element exposing (..)
|
||||
import Http
|
||||
import Iso8601
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode
|
||||
import Markdown
|
||||
import Time
|
||||
import Todo.Body as Body exposing (Body)
|
||||
import Todo.Tag as Tag exposing (Tag)
|
||||
import Todo.UUID exposing (UUID)
|
||||
import Username as Username exposing (Username)
|
||||
import Viewer exposing (Viewer)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
{-| An Todo, optionally with an Todo body.
|
||||
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
|
||||
consider the difference between the "view individual Todo" page (which
|
||||
renders one Todo, including its body) and the "Todo feed" -
|
||||
which displays multiple Todos, but without bodies.
|
||||
This definition for `Todo` means we can write:
|
||||
viewTodo : Todo Full -> Html msg
|
||||
viewFeed : List (Todo Preview) -> Html msg
|
||||
This indicates that `viewTodo` requires an Todo _with a `body` present_,
|
||||
wereas `viewFeed` accepts Todos with no bodies. (We could also have written
|
||||
it as `List (Todo a)` to specify that feeds can accept either Todos that
|
||||
have `body` present or not. Either work, given that feeds do not attempt to
|
||||
read the `body` field from Todos.)
|
||||
This is an important distinction, because in Request.Todo, the `feed`
|
||||
function produces `List (Todo Preview)` because the API does not return bodies.
|
||||
Those Todos are useful to the feed, but not to the individual Todo view.
|
||||
-}
|
||||
type Todo a
|
||||
= Todo Internals a
|
||||
|
||||
|
||||
{-| Metadata about the Todo - its title, description, and so on.
|
||||
Importantly, this module's public API exposes a way to read this metadata, but
|
||||
not to alter it. This is read-only information!
|
||||
If we find ourselves using any particular piece of metadata often,
|
||||
for example `title`, we could expose a convenience function like this:
|
||||
Todo.title : Todo a -> String
|
||||
If you like, it's totally reasonable to expose a function like that for every one
|
||||
of these fields!
|
||||
(Okay, to be completely honest, exposing one function per field is how I prefer
|
||||
to do it, and that's how I originally wrote this module. However, I'm aware that
|
||||
this code base has become a common reference point for beginners, and I think it
|
||||
is _extremely important_ that slapping some "getters and setters" on a record
|
||||
does not become a habit for anyone who is getting started with Elm. The whole
|
||||
point of making the Todo type opaque is to create guarantees through
|
||||
_selectively choosing boundaries_ around it. If you aren't selective about
|
||||
where those boundaries are, and instead expose a "getter and setter" for every
|
||||
field in the record, the result is an API with no more guarantees than if you'd
|
||||
exposed the entire record directly! It is so important to me that beginners not
|
||||
fall into the terrible "getters and setters" trap that I've exposed this
|
||||
Metadata record instead of exposing a single function for each of its fields,
|
||||
as I did originally. This record is not a bad way to do it, by any means,
|
||||
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
|
||||
)
|
||||
-}
|
||||
type alias Metadata =
|
||||
{ description : String
|
||||
, title : String
|
||||
, tags : List String
|
||||
, createdAt : Time.Posix
|
||||
, favorited : Bool
|
||||
, favoritesCount : Int
|
||||
}
|
||||
|
||||
|
||||
type alias Internals =
|
||||
{ uuid : UUID
|
||||
, author : Author
|
||||
, metadata : Metadata
|
||||
}
|
||||
|
||||
|
||||
type Preview
|
||||
= Preview
|
||||
|
||||
|
||||
type Full
|
||||
= Full Body
|
||||
|
||||
|
||||
|
||||
-- INFO
|
||||
|
||||
|
||||
author : Todo a -> Author
|
||||
author (Todo internals _) =
|
||||
internals.author
|
||||
|
||||
|
||||
metadata : Todo a -> Metadata
|
||||
metadata (Todo internals _) =
|
||||
internals.metadata
|
||||
|
||||
|
||||
uuid : Todo a -> UUID
|
||||
uuid (Todo internals _) =
|
||||
internals.uuid
|
||||
|
||||
|
||||
body : Todo Full -> Body
|
||||
body (Todo _ (Full extraInfo)) =
|
||||
extraInfo
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
{-| This is the only way you can transform an existing Todo:
|
||||
you can change its author (e.g. to follow or unfollow them).
|
||||
All other Todo data necessarily comes from the server!
|
||||
We can tell this for sure by looking at the types of the exposed functions
|
||||
in this module.
|
||||
-}
|
||||
mapAuthor : (Author -> Author) -> Todo a -> Todo a
|
||||
mapAuthor transform (Todo info extras) =
|
||||
Todo { info | author = transform info.author } extras
|
||||
|
||||
|
||||
fromPreview : Body -> Todo Preview -> Todo Full
|
||||
fromPreview newBody (Todo info Preview) =
|
||||
Todo info (Full newBody)
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
|
||||
|
||||
previewDecoder : Maybe Cred -> Decoder (Todo Preview)
|
||||
previewDecoder maybeCred =
|
||||
Decode.succeed Todo
|
||||
|> custom (internalsDecoder maybeCred)
|
||||
|> hardcoded Preview
|
||||
|
||||
|
||||
fullDecoder : Maybe Cred -> Decoder (Todo Full)
|
||||
fullDecoder maybeCred =
|
||||
Decode.succeed Todo
|
||||
|> custom (internalsDecoder maybeCred)
|
||||
|> required "body" (Decode.map Full Body.decoder)
|
||||
|
||||
|
||||
internalsDecoder : Maybe Cred -> Decoder Internals
|
||||
internalsDecoder maybeCred =
|
||||
Decode.succeed Internals
|
||||
|> required "uuid" UUID.decoder
|
||||
|> required "author" (Author.decoder maybeCred)
|
||||
|> custom metadataDecoder
|
||||
|
||||
|
||||
metadataDecoder : Decoder Metadata
|
||||
metadataDecoder =
|
||||
Decode.succeed Metadata
|
||||
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|
||||
|> required "title" Decode.string
|
||||
|> required "tagList" (Decode.list Decode.string)
|
||||
|> required "createdAt" Iso8601.decoder
|
||||
|> required "favorited" Decode.bool
|
||||
|> required "favoritesCount" Decode.int
|
||||
|
||||
|
||||
|
||||
-- SINGLE
|
||||
|
||||
|
||||
fetch : Maybe Cred -> UUID -> Http.Request (Todo Full)
|
||||
fetch maybeCred uuid =
|
||||
Decode.field "Todo" (fullDecoder maybeCred)
|
||||
|> Api.get (Endpoint.Todo uuid) maybeCred
|
||||
|
||||
|
||||
|
||||
-- FAVORITE
|
||||
|
||||
|
||||
favorite : UUID -> Cred -> Http.Request (Todo Preview)
|
||||
favorite uuid cred =
|
||||
Api.post (Endpoint.favorite uuid) (Just cred) Http.emptyBody (faveDecoder cred)
|
||||
|
||||
|
||||
unfavorite : UUID -> Cred -> Http.Request (Todo Preview)
|
||||
unfavorite uuid cred =
|
||||
Api.delete (Endpoint.favorite uuid) cred Http.emptyBody (faveDecoder cred)
|
||||
|
||||
|
||||
faveDecoder : Cred -> Decoder (Todo Preview)
|
||||
faveDecoder cred =
|
||||
Decode.field "Todo" (previewDecoder (Just cred))
|
||||
|
||||
|
||||
{-| This is a "build your own element" API.
|
||||
You pass it some configuration, followed by a `List (Attribute msg)` and a
|
||||
`List (Html msg)`, just like any standard Html element.
|
||||
-}
|
||||
favoriteButton :
|
||||
Cred
|
||||
-> msg
|
||||
-> List (Attribute msg)
|
||||
-> List (Element msg)
|
||||
-> Element msg
|
||||
favoriteButton _ msg attrs kids =
|
||||
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
|
||||
|
||||
|
||||
unfavoriteButton :
|
||||
Cred
|
||||
-> msg
|
||||
-> List (Attribute msg)
|
||||
-> List (Element msg)
|
||||
-> Element msg
|
||||
unfavoriteButton _ msg attrs kids =
|
||||
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
|
||||
|
||||
|
||||
toggleFavoriteButton :
|
||||
String
|
||||
-> msg
|
||||
-> List (Attribute msg)
|
||||
-> List (Element msg)
|
||||
-> Element msg
|
||||
toggleFavoriteButton classStr msg attrs kids =
|
||||
Html.button
|
||||
(class classStr :: onClickStopPropagation msg :: attrs)
|
||||
(i [ class "ion-heart" ] [] :: kids)
|
||||
|
||||
|
||||
onClickStopPropagation : msg -> Attribute msg
|
||||
onClickStopPropagation msg =
|
||||
stopPropagationOn "click"
|
||||
(Decode.succeed ( msg, True ))
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
module Todo.UUID exposing (UUID, decoder, toString, urlParser)
|
||||
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Url.Parser exposing (Parser)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type UUID
|
||||
= UUID String
|
||||
|
||||
|
||||
|
||||
-- CREATE
|
||||
|
||||
|
||||
urlParser : Parser (UUID -> a) a
|
||||
urlParser =
|
||||
Url.Parser.custom "UUID" (\str -> Just (UUID str))
|
||||
|
||||
|
||||
decoder : Decoder UUID
|
||||
decoder =
|
||||
Decode.map UUID Decode.string
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
toString : UUID -> String
|
||||
toString (UUID str) =
|
||||
str
|
||||
module Todo.UUID exposing (UUID, decoder, toString, urlParser)
|
||||
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Url.Parser exposing (Parser)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type UUID
|
||||
= UUID String
|
||||
|
||||
|
||||
|
||||
-- CREATE
|
||||
|
||||
|
||||
urlParser : Parser (UUID -> a) a
|
||||
urlParser =
|
||||
Url.Parser.custom "UUID" (\str -> Just (UUID str))
|
||||
|
||||
|
||||
decoder : Decoder UUID
|
||||
decoder =
|
||||
Decode.map UUID Decode.string
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
toString : UUID -> String
|
||||
toString (UUID str) =
|
||||
str
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
module Username exposing (Username, decoder, encode, toHtml, toString, urlParser)
|
||||
|
||||
import Element exposing (..)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
import Url.Parser
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Username
|
||||
= Username String
|
||||
|
||||
|
||||
|
||||
-- CREATE
|
||||
|
||||
|
||||
decoder : Decoder Username
|
||||
decoder =
|
||||
Decode.map Username Decode.string
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
encode : Username -> Value
|
||||
encode (Username username) =
|
||||
Encode.string username
|
||||
|
||||
|
||||
toString : Username -> String
|
||||
toString (Username username) =
|
||||
username
|
||||
|
||||
|
||||
urlParser : Url.Parser.Parser (Username -> a) a
|
||||
urlParser =
|
||||
Url.Parser.custom "USERNAME" (\str -> Just (Username str))
|
||||
|
||||
|
||||
toHtml : Username -> Element msg
|
||||
toHtml (Username username) =
|
||||
text username
|
||||
module Username exposing (Username, decoder, encode, toHtml, toString, urlParser)
|
||||
|
||||
import Element exposing (..)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
import Url.Parser
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Username
|
||||
= Username String
|
||||
|
||||
|
||||
|
||||
-- CREATE
|
||||
|
||||
|
||||
decoder : Decoder Username
|
||||
decoder =
|
||||
Decode.map Username Decode.string
|
||||
|
||||
|
||||
|
||||
-- TRANSFORM
|
||||
|
||||
|
||||
encode : Username -> Value
|
||||
encode (Username username) =
|
||||
Encode.string username
|
||||
|
||||
|
||||
toString : Username -> String
|
||||
toString (Username username) =
|
||||
username
|
||||
|
||||
|
||||
urlParser : Url.Parser.Parser (Username -> a) a
|
||||
urlParser =
|
||||
Url.Parser.custom "USERNAME" (\str -> Just (Username str))
|
||||
|
||||
|
||||
toHtml : Username -> Element msg
|
||||
toHtml (Username username) =
|
||||
text username
|
||||
|
|
|
@ -1,66 +1,66 @@
|
|||
module Viewer exposing (Viewer, avatar, cred, decoder, minPasswordChars, store, username)
|
||||
|
||||
{-| The logged-in user currently viewing this page. It stores enough data to
|
||||
be able to render the menu bar (username and avatar), along with Cred so it's
|
||||
impossible to have a Viewer if you aren't logged in.
|
||||
-}
|
||||
|
||||
import Api exposing (Cred)
|
||||
import Avatar exposing (Avatar)
|
||||
import Email exposing (Email)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Decode.Pipeline exposing (custom, required)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
import Profile exposing (Profile)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Viewer
|
||||
= Viewer Avatar Cred
|
||||
|
||||
|
||||
|
||||
-- INFO
|
||||
|
||||
|
||||
cred : Viewer -> Cred
|
||||
cred (Viewer _ val) =
|
||||
val
|
||||
|
||||
|
||||
username : Viewer -> Username
|
||||
username (Viewer _ val) =
|
||||
Api.username val
|
||||
|
||||
|
||||
avatar : Viewer -> Avatar
|
||||
avatar (Viewer val _) =
|
||||
val
|
||||
|
||||
|
||||
{-| Passwords must be at least this many characters long!
|
||||
-}
|
||||
minPasswordChars : Int
|
||||
minPasswordChars =
|
||||
6
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
|
||||
|
||||
decoder : Decoder (Cred -> Viewer)
|
||||
decoder =
|
||||
Decode.succeed Viewer
|
||||
|> custom (Decode.field "image" Avatar.decoder)
|
||||
|
||||
|
||||
store : Viewer -> Cmd msg
|
||||
store (Viewer avatarVal credVal) =
|
||||
Api.storeCredWith
|
||||
credVal
|
||||
avatarVal
|
||||
module Viewer exposing (Viewer, avatar, cred, decoder, minPasswordChars, store, username)
|
||||
|
||||
{-| The logged-in user currently viewing this page. It stores enough data to
|
||||
be able to render the menu bar (username and avatar), along with Cred so it's
|
||||
impossible to have a Viewer if you aren't logged in.
|
||||
-}
|
||||
|
||||
import Api exposing (Cred)
|
||||
import Avatar exposing (Avatar)
|
||||
import Email exposing (Email)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Decode.Pipeline exposing (custom, required)
|
||||
import Json.Encode as Encode exposing (Value)
|
||||
import Profile exposing (Profile)
|
||||
import Username exposing (Username)
|
||||
|
||||
|
||||
|
||||
-- TYPES
|
||||
|
||||
|
||||
type Viewer
|
||||
= Viewer Avatar Cred
|
||||
|
||||
|
||||
|
||||
-- INFO
|
||||
|
||||
|
||||
cred : Viewer -> Cred
|
||||
cred (Viewer _ val) =
|
||||
val
|
||||
|
||||
|
||||
username : Viewer -> Username
|
||||
username (Viewer _ val) =
|
||||
Api.username val
|
||||
|
||||
|
||||
avatar : Viewer -> Avatar
|
||||
avatar (Viewer val _) =
|
||||
val
|
||||
|
||||
|
||||
{-| Passwords must be at least this many characters long!
|
||||
-}
|
||||
minPasswordChars : Int
|
||||
minPasswordChars =
|
||||
6
|
||||
|
||||
|
||||
|
||||
-- SERIALIZATION
|
||||
|
||||
|
||||
decoder : Decoder (Cred -> Viewer)
|
||||
decoder =
|
||||
Decode.succeed Viewer
|
||||
|> custom (Decode.field "image" Avatar.decoder)
|
||||
|
||||
|
||||
store : Viewer -> Cmd msg
|
||||
store (Viewer avatarVal credVal) =
|
||||
Api.storeCredWith
|
||||
credVal
|
||||
avatarVal
|
||||
|
|
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
1
frontend/README.md
Normal file
1
frontend/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
This is a starter template for [Learn Next.js](https://nextjs.org/learn).
|
2066
frontend/package-lock.json
generated
Normal file
2066
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "todo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^10.0.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1"
|
||||
}
|
||||
}
|
209
frontend/pages/index.js
Normal file
209
frontend/pages/index.js
Normal file
|
@ -0,0 +1,209 @@
|
|||
import Head from 'next/head'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="container">
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main>
|
||||
<h1 className="title">
|
||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
||||
</h1>
|
||||
|
||||
<p className="description">
|
||||
Get started by editing <code>pages/index.js</code>
|
||||
</p>
|
||||
|
||||
<div className="grid">
|
||||
<a href="https://nextjs.org/docs" className="card">
|
||||
<h3>Documentation →</h3>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://nextjs.org/learn" className="card">
|
||||
<h3>Learn →</h3>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
||||
className="card"
|
||||
>
|
||||
<h3>Examples →</h3>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/import?filter=next.js&utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className="card"
|
||||
>
|
||||
<h3>Deploy →</h3>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{' '}
|
||||
<img src="/vercel.svg" alt="Vercel" className="logo" />
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<style jsx>{`
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
padding: 0 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 5rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
border-top: 1px solid #eaeaea;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
footer img {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono,
|
||||
DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
max-width: 800px;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
flex-basis: 45%;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<style jsx global>{`
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
1889
frontend/pnpm-lock.yaml
Normal file
1889
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
4
frontend/public/vercel.svg
Normal file
4
frontend/public/vercel.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in a new issue