Adding profile changes.

This commit is contained in:
sergiotarxz 2022-12-19 00:41:13 +01:00
parent cbccb35ee8
commit 8adc44f6c1
22 changed files with 872 additions and 36 deletions

View File

@ -9,18 +9,27 @@ my $build = Module::Build->new(
dist_author => 'Sergio Iglesias <contact@owlcode.tech>',
dist_abstract => 'Redland Official user management.',
requires => {
'Mojolicious' => 0,
'DBI' => 0,
'Path::Tiny' => 0,
'DBD::Pg' => 0,
'Const::Fast' => 0,
'DateTime::Format::ISO8601' => 0,
'DateTime::Format::Mail' => 0,
'Crypt::Bcrypt' => 0,
'SVG' => 0,
'JSON' => 0,
'Moo' => 0,
'Types::Standard' => 0,
'Mojolicious' => 0,
'DBI' => 0,
'Path::Tiny' => 0,
'DBD::Pg' => 0,
'Const::Fast' => 0,
'DateTime::Format::ISO8601' => 0,
'DateTime::Format::Mail' => 0,
'Crypt::Bcrypt' => 0,
'SVG' => 0,
'JSON' => 0,
'Moo' => 0,
'Types::Standard' => 0,
'Params::ValidationCompiler' => 0,
'Crypt::URandom' => 0,
'DateTime::Format::Pg' => 0,
'Email::MIME' => 0,
'Email::Sender::Simple' => 0,
'Email::Sender::Transport::SMTP' => 0,
'List::AllUtils' => 0,
'Capture::Tiny' => 0,
},
);
$build->create_build_script;

135
js/functions.js Normal file
View File

@ -0,0 +1,135 @@
"use strict";
import React, { useState, useRef } from "react";
import ReactCrop, {
centerCrop,
makeAspectCrop,
PixelCrop,
} from "react-image-crop";
import "react-image-crop/src/ReactCrop.scss";
export async function blobImgCrop(image, crop) {
const canvas = document.createElement("canvas");
canvasPreview(image, canvas, crop);
const blob = await toBlob(canvas);
if (!blob) {
console.error("Failed to create blob");
return "";
}
return blob;
}
function toBlob(canvas) {
return new Promise((resolve) => {
canvas.toBlob(resolve);
});
}
export async function canvasPreview(image, canvas, crop) {
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("No 2d context");
}
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
// devicePixelRatio slightly increases sharpness on retina devices
// at the expense of slightly slower render times and needing to
// size the image back down if you want to download/upload and be
// true to the images natural size.
const pixelRatio = window.devicePixelRatio;
// const pixelRatio = 1
canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
ctx.scale(pixelRatio, pixelRatio);
ctx.imageSmoothingQuality = "high";
const cropX = crop.x * scaleX;
const cropY = crop.y * scaleY;
const centerX = image.naturalWidth / 2;
const centerY = image.naturalHeight / 2;
ctx.save();
// 5) Move the crop origin to the canvas origin (0,0)
ctx.translate(-cropX, -cropY);
// 4) Move the origin to the center of the original position
ctx.translate(centerX, centerY);
// 1) Move the center of the image to the origin (0,0)
ctx.translate(-centerX, -centerY);
ctx.drawImage(
image,
0,
0,
image.naturalWidth,
image.naturalHeight,
0,
0,
image.naturalWidth,
image.naturalHeight
);
ctx.restore();
}
export function AvatarCropper(props) {
const [crop, setCrop] = React.useState({
unit: "%",
x: 10,
y: 10,
width: 80,
height: 80,
});
if (props["onStart"]) {
props["onStart"](crop);
}
return React.createElement(
ReactCrop,
{
aspect: 1,
crop: crop,
onChange: (c) => {
setCrop(c);
if (props["onChange"]) props["onChange"](c);
},
minWidth: 50,
minHeight: 50,
onComplete: (local_crop, pcrop) => {
if (props["onComplete"]) {
props["onComplete"](local_crop);
}
},
},
React.createElement("img", {
src: props.src,
onLoad: (e) => {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
const crop_local = centerCrop(
makeAspectCrop(
{
width: 50,
height: 50,
},
1,
width,
height
),
width,
height
);
setCrop(crop_local);
console.log(props["onLoad"]);
if (props["onLoad"]) {
props["onLoad"](crop_local);
}
},
})
);
}

90
js/index.js Normal file
View File

@ -0,0 +1,90 @@
"use strict";
import React, { useState, useRef } from "react";
import * as ReactDOM from "react-dom/client";
import * as f from "/js/functions";
const avatar_cropper = document.querySelector("#avatar-cropper");
const avatar_selector = document.querySelector("#avatar-selector");
const button_select_avatar = document.querySelector("#select-avatar");
const root = ReactDOM.createRoot(avatar_cropper);
if (!window.a) {
window.a = require("/js/index");
}
export let crop;
export let img_to_crop;
let avatar_selector_on_file_lambda = () => {
if (avatar_selector.files && avatar_selector.files.length > 0) {
const reader = new FileReader();
reader.addEventListener("load", () => {
const image = new Image();
// We use the event listener load to check if it is a valid image.
// This check has to be repeated in the server of course.
image.addEventListener("load", () => {
avatar_cropper.style.display = "block";
const local_image_to_crop = document.createElement("img");
local_image_to_crop.src = reader.result?.toString();
img_to_crop = local_image_to_crop;
if (img_to_crop.width < 50 || img_to_crop.height < 50) {
alert('El avatar es demasiado pequeño, busca un imagen de al menos 50x50 pixeles.');
return true;
}
root.render(
React.createElement(f.AvatarCropper, {
src: reader.result?.toString() || "",
onStart: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
onChange: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
onLoad: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
onComplete: (c) => {
crop = c;
button_select_avatar.removeAttribute("disabled");
},
})
);
});
image.addEventListener("error", () => {
alert("No parece que esto sea una imagen.");
});
image.src = reader.result?.toString();
});
reader.readAsDataURL(avatar_selector.files[0]);
}
};
avatar_selector_on_file_lambda();
avatar_selector.addEventListener("change", avatar_selector_on_file_lambda);
button_select_avatar.addEventListener("click", () => {
if (img_to_crop.src == null || crop == null) {
console.log("Still not ready to transmit");
}
const form_data = new FormData();
f.blobImgCrop(img_to_crop, crop).then((result) => {
form_data.append('file', result);
fetch('/usuario/actualizar-avatar', {
method: 'POST',
body: form_data,
}).then((response) => {
if (response.ok) {
window.location = '/perfil';
} else {
response.json().then((object) => {
alert(object.description);
});
}
});
});
return false;
});

View File

@ -1,5 +1,6 @@
package MyRedland;
use MyRedland::Lusers;
use MyRedland::Controller::Metrics;
use Mojo::Base 'Mojolicious', -signatures;
@ -21,6 +22,21 @@ sub startup ($self) {
$self->config(
hypnotoad => { proxy => 1, listen => ['http://localhost:3000'] } );
$self->secrets($self->config->{secrets});
$self->sessions->default_expiration(0);
my $luser_dao = MyRedland::Lusers->new(app => $self);
$self->helper( current_user => sub {
my $c = shift;
my $user_uuid = $c->session->{user_uuid};
if (!defined $user_uuid) {
return;
}
my $user = $luser_dao->find_by_uuid(uuid => $user_uuid);
if (!defined $user) {
delete $c->session->{user_uuid};
return;
}
return $user;
});
# Router
my $r = $self->routes;
@ -29,6 +45,18 @@ sub startup ($self) {
$r->get('/')->to('Page#index');
# $r->get('/:post')->to('Page#post');
$r->get('/perfil')->to('User#profile');
$r->get('/usuario/:username/verificacion')->to('User#user_verification');
$r->get('/usuario/avatar')->to('User#get_avatar');
$r->get('/perfil/verifica-el-correo')->to('User#mail_verify');
$r->post('/usuario/actualizar-avatar')->to('User#setup_avatar');
$r->get('/perfil/configura-tu-avatar')->to('User#setup_avatar_get');
$r->get('/logout')->to('User#logout_get');
$r->post('/logout')->to('User#logout');
$r->get('/login')->to('User#login_get');
$r->post('/login')->to('User#login');
$r->get('/registrate')->to('User#sign_up_get');
$r->post('/registrate')->to('User#sign_up');
$r->get('/stats')->to('Metrics#stats');
$r->get('/<:category>.rss')->to('Page#category_rss');
$r->get('/:category')->to('Page#category');

View File

@ -36,6 +36,8 @@ sub MIGRATIONS {
)',
'ALTER TABLE lusers ADD COLUMN avatar TEXT DEFAULT \'\'',
'ALTER TABLE lusers ADD COLUMN mail_verification_expiration TIMESTAMP DEFAULT NOW() + interval \'1 day\'',
'ALTER TABLE lusers ADD COLUMN creation_date TIMESTAMP DEFAULT NOW()',
'ALTER TABLE lusers ADD COLUMN last_access TIMESTAMP DEFAULT NOW()',
);
}
1;

View File

@ -6,7 +6,9 @@ use strict;
use warnings;
use Moo;
use Types::Standard qw/Str Bool/;
use Types::Standard qw/Str Bool InstanceOf/;
use Crypt::Bcrypt qw/bcrypt bcrypt_check/;
has uuid => (
is => 'ro',
@ -46,7 +48,36 @@ has mail_verification_payload => (
has mail_verification_expiration => (
is => 'rw',
isa => Str,
isa => InstanceOf['DateTime'],
required => 0,
);
has creation_date => (
is => 'ro',
isa => InstanceOf['DateTime']
);
has last_access => (
is => 'rw',
isa => InstanceOf['DateTime'],
);
has avatar => (
is => 'rw',
isa => Str,
);
sub check_password {
my $self = shift;
my $password = shift;
if (!defined $password) {
warn "Password not defined";
return;
}
if (!length $password) {
# Not accepting empty passwords.
return;
}
return bcrypt_check($password, $self->password);
}
1;

View File

@ -5,12 +5,177 @@ use v5.34.1;
use strict;
use warnings;
use DateTime;
use DateTime::Format::Pg;
use Moo;
use Types::Standard qw/InstanceOf/;
use Crypt::URandom;
use Types::Standard qw/InstanceOf Str ArrayRef/;
use Params::ValidationCompiler qw/validation_for/;
use MyRedland::DB;
use MyRedland::Luser;
use List::AllUtils qw/any/;
my $fpg = DateTime::Format::Pg->new;
my @FIELDS = qw/uuid username email verified
password mail_verification_payload avatar
mail_verification_expiration creation_date
last_access/;
has app => (
is => 'rw',
isa => InstanceOf['Mojolicious'],
required => 1,
is => 'rw',
isa => InstanceOf ['Mojolicious'],
required => 1,
);
has dbh => ( is => 'lazy', );
sub _build_dbh {
my $self = shift;
return MyRedland::DB->connect( $self->app );
}
{
my $validator = validation_for(
params => {
username => { type => Str },
email => { type => Str },
password => { type => Str },
}
);
sub create {
my $self = shift;
my %params = $validator->(@_);
my $dbh = $self->dbh;
my $username = $params{username};
my $email = $params{email};
my $password = $params{password};
my $test_mode = $self->app->config->{test_mode};
my $verified = 0;
if ($test_mode) {
$verified = 1;
}
my $mail_verification_payload = unpack 'H*',
Crypt::URandom::urandom(30);
my $avatar = '';
my $mail_verification_expiration =
$fpg->format_datetime( DateTime->now->add( days => 1 ) );
my $user_hash = $dbh->selectrow_hashref(
<<"EOF", undef, $username, $email, $verified, $password, $mail_verification_payload, $avatar, $mail_verification_expiration );
INSERT INTO lusers
(username, email, verified, password,
mail_verification_payload, avatar,
mail_verification_expiration)
VALUES
(?, ?, ?, ?, ?, ?, ?)
RETURNING @{[join ', ', @FIELDS]};
EOF
$self->_convert_user_hash_dates($user_hash);
my $user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
{
my $validator = validation_for(
params => {
user => { type => InstanceOf ['MyRedland::Luser'] },
fields => { type => ArrayRef [Str] },
}
);
sub update {
my $self = shift;
my %params = $validator->(@_);
my $user = $params{user};
my $fields = $params{fields};
my %updates;
for my $field (@$fields) {
if ( any { $field eq $_ } qw/verified email password mail_verification_expiration mail_verification_payload last_access avatar/) {
$updates{$field} = $user->$field;
next;
}
die "No such field $field.";
}
my $query = <<"EOF";
UPDATE lusers
SET @{[
join ', ', map { "$_ = ?" } @$fields
]} WHERE uuid = ?
RETURNING @{[join ', ', @FIELDS]};
EOF
my $dbh = $self->dbh;
my $user_hash = $dbh->selectrow_hashref($query, undef, @updates{@$fields}, $user->uuid);
$self->_convert_user_hash_dates($user_hash);
$user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
sub _convert_user_hash_dates {
my $self = shift;
my $user_hash = shift;
$user_hash->{mail_verification_expiration} =
$fpg->parse_datetime( $user_hash->{mail_verification_expiration} );
$user_hash->{creation_date} =
$fpg->parse_datetime( $user_hash->{creation_date} );
$user_hash->{last_access} =
$fpg->parse_datetime( $user_hash->{last_access} );
}
{
my $validator = validation_for(
params => {
uuid => { type => Str },
}
);
sub find_by_uuid {
my $self = shift;
my %params = $validator->(@_);
my $uuid = $params{uuid};
my $dbh = $self->dbh;
my $user_hash = $dbh->selectrow_hashref( <<"EOF", undef, $uuid );
SELECT @{[join ', ', @FIELDS]} FROM lusers where uuid = ?;
EOF
if ( !defined $user_hash ) {
return;
}
$self->_convert_user_hash_dates($user_hash);
my $user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
{
my $validator = validation_for(
params => {
username => { type => Str },
}
);
sub find_by_username {
my $self = shift;
my %params = $validator->(@_);
my $username = $params{username};
my $dbh = $self->dbh;
my $user_hash = $dbh->selectrow_hashref( <<"EOF", undef, $username );
SELECT @{[join ', ', @FIELDS]} FROM lusers where username = ?;
EOF
if ( !defined $user_hash ) {
return;
}
$self->_convert_user_hash_dates($user_hash);
my $user = MyRedland::Luser->new(%$user_hash);
return $user;
}
}
1;

117
lib/MyRedland/Mail.pm Normal file
View File

@ -0,0 +1,117 @@
package MyRedland::Mail;
use v5.34.1;
use strict;
use warnings;
use Mojolicious;
use Encode qw/decode/;
use Moo;
use Types::Standard qw/InstanceOf Str/;
use Params::ValidationCompiler qw/validation_for/;
use Email::MIME;
use Email::Sender::Simple;
use Email::Sender::Transport::SMTP;
has app => (
is => 'ro',
isa => InstanceOf ['Mojolicious'],
);
has _config => ( is => 'lazy', );
has _transport => ( is => 'lazy', );
sub _build__config {
my $self = shift;
my $app = $self->app;
if ( defined $app->config->{smtp} ) {
return $app->config->{smtp};
}
return;
}
sub _build__transport {
my $self = shift;
my $smtp_config = $self->_config;
if ( !$smtp_config ) {
die "Unable to build transport due the lack of related config.";
}
my %options = %$smtp_config;
delete $options{From};
my $tranport = Email::Sender::Transport::SMTP->new(%$smtp_config);
return $tranport;
}
{
my $validator = validation_for(
params => {
text => { type => Str },
html => { type => Str },
to => { type => Str },
subject => { type => Str },
}
);
sub sendmail {
my $self = shift;
my %params = $validator->(@_);
my $transport = $self->_transport;
my $from = $self->_config->{From};
my ( $text, $html, $to, $subject ) = @params{qw/text html to subject/};
my @parts = (
Email::MIME->create(
attributes => {
charset => 'UTF-8',
content_type => 'text/plain',
encoding => 'base64',
disposition => 'inline',
},
body_str => $text,
),
Email::MIME->create(
attributes => {
charset => 'UTF-8',
content_type => 'text/html',
encoding => 'base64',
disposition => 'inline',
},
body_str => $html,
),
);
my $email = Email::MIME->create(
header_str => [
From => $from,
to => $to,
subject => $subject,
],
attributes => {
encoding => 'base64',
content_type => 'multipart/mixed',
},
parts => [
Email::MIME->create(
attributes => {
content_type => 'multipart/alternative',
encoding => 'base64',
},
parts => [ @parts, ],
)
]
);
eval {
Email::Sender::Simple->send( $email, { transport => $self->_transport });
};
if ($@) {
warn $@;
return 0;
}
return 1;
}
}
1;

View File

@ -6,5 +6,12 @@
},
"stripe_secret": "secret",
"stripe_public": "public",
"test_mode": 1
"test_mode": 1,
"smtp": {
"ssl": 1,
"hosts": [ "example.com" ],
"sasl_username": "exampleuser@example.com",
"sasl_password": "[redacted]",
"From": "Example User <exampleuser@example.com>"
}
}

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "myredland",
"version": "0.0.1",
"description": "Frontend for MyRedland",
"private": true,
"directories": {
"lib": "js"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"repository": {
"type": "git",
"url": "git@git.owlcode.tech:sergiotarxz/MyRedland.git"
},
"author": "Sergio Iglesias",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"babel-cli": "^6.26.0",
"babel-preset-react-app": "^3.1.2",
"browserify": "^17.0.0",
"react-image-crop": "^10.0.9",
"react-scripts": "5.0.1",
"sass": "^1.56.2",
"web-vitals": "^2.1.4"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"css-loader": "^6.7.3",
"prettier": "^2.8.1",
"sass-loader": "^13.2.0",
"style-loader": "^3.3.1",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
}
}

View File

@ -34,6 +34,36 @@ body {
background-size: cover;
overflow: hidden;
}
div.divider {
display: flex;
flex-direction: row;
height: 100%;
div.side-menu {
width: 30%;
background: $color_div;
a {
display: inline-flex;
min-height: 60px;
justify-content: center;
text-align: center;
vertical-align: middle;
flex-direction: column;
width: 100%;
font-size: 24px;
color: #DC143C;
border-bottom: #36454F 3px solid;
text-decoration: none;
background: $color_div;
&:hover {
color: $color_div;
background: $background_div;
}
}
}
div.main-page-contents {
width: 100%;
}
}
div.page-contents {
position: absolute;
position: fixed;
@ -69,6 +99,9 @@ body {
b,p,li {
font-size: 40px;
}
small {
font-size: 20px;
}
h3 {
font-size: 47px;
}
@ -175,11 +208,38 @@ body {
width: 100%;
display: none;
a {
height: 100%;
vertical-align: middle;
background: $background_div;
}
}
nav {
a.common-user-button {
height: 50px;
width: 50px;
float: right;
display: block;
margin-top: 3px;
margin-right: 10px;
border-radius: 50%;
background: $background-page;
}
a.login-button {
padding-top: 10px;
padding-bottom: 10px;
img {
width: 30px;
}
}
a.profile-button {
padding-left: 0px;
padding-right: 0px;
img {
width: 50px;
height: 50px;
border-radius: 50%;
}
}
overflow: auto;
display: block;
font-size: 35px;
@ -273,7 +333,8 @@ body {
}
nav.desktop {
max-height: 60px;
display: block;
display: flex;
justify-content: space-between;
height: auto;
a {
display: table-cell;

BIN
public/img/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

5
public/img/person.svg Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16" class="svg octicon-person" width="16" height="16" aria-hidden="true">
<path fill-rule="evenodd" d="M10.5 5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zm.061 3.073a4 4 0 1 0-5.123 0 6.004 6.004 0 0 0-3.431 5.142.75.75 0 0 0 1.498.07 4.5 4.5 0 0 1 8.99 0 .75.75 0 1 0 1.498-.07 6.005 6.005 0 0 0-3.432-5.142z"></path>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@ -1,3 +1,11 @@
% my $categories = stash 'categories';
% if (!defined $categories) {
% $categories = MyRedland::Categories->new->Retrieve;
% }
% my $current_slug = stash 'current_slug';
% if (!defined $current_slug) {
% $current_slug = $categories->{index}{slug};
% }
<!DOCTYPE html>
<html lang="es">
<head>
@ -20,20 +28,28 @@
<div class="site-wrapper">
</div>
<div class="page-contents">
<nav class="desktop"><% for my $category_key (sort {
<nav class="desktop"><div><% for my $category_key (sort {
$categories->{$a}{priority} <=> $categories->{$b}{priority}
} keys %$categories) {
my $category = $categories->{$category_key};
my $selected = defined($current_slug) && $category->{slug} eq $current_slug;
%><a class="<%=$selected && "selected" %>" href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
}
%></nav>
<nav class="mobile-shortcuts">
%></div><%
if ($self->current_user) {
if ($self->current_user->avatar) {
%><a class="common-user-button profile-button" href="/perfil"><img alt="Tu avatar" src="/usuario/avatar"/></a><%
} else {
%><a class="common-user-button login-button" href="/perfil"><img alt="Icono de login" src="/img/person.svg"/></a><%
}
} else {
%><a class="common-user-button login-button" href="/login"><img alt="Icono de login" src="/img/person.svg"/></a><%
}
%></nav><nav class="mobile-shortcuts">
<a class="go-to-index" href="<%='/'.$categories->{index}{slug}%>"><%== $categories->{index}{menu_text} %></a>
<div></div>
<a href="#mobile-foldable" class="menu-expand"><img class="open-menu-icon" src="/img/menu.png" alt="Expandir el menú."/></a>
</nav>
<nav class="mobile-foldable" id="mobile-foldable"><%
</nav><nav class="mobile-foldable" id="mobile-foldable"><%
my $first = 1;
for my $category_key (sort {
$categories->{$a}{priority} <=> $categories->{$b}{priority}
@ -41,16 +57,24 @@
my $category = $categories->{$category_key};
%><a href="<%= '/'.$category->{slug} %>"><%==$category->{menu_text}%></a><%
}
%></nav>
<%= content %></body>
<hr/>
<div class="footer description">
<p>©2022 Sergio Iglesias</p>
<p>Enterate de todas las novedades de Redland Official:</p>
<a class="suscribe-category-rss" href="/all.rss">
<img src="/img/rss.svg" alt="Icono de suscripción rss"/>
</a>
%></nav><div class="divider">
% if (stash 'side_menu') {
<div class="side-menu">
<%= content 'side_menu' %>
</div>
% }
<div class="main-page-contents">
<%= content %>
<hr/>
<div class="footer description">
<p>©2022 Sergio Iglesias</p>
<p>Enterate de todas las novedades de Redland Official:</p>
<a class="suscribe-category-rss" href="/all.rss">
<img src="/img/rss.svg" alt="Icono de suscripción rss"/>
</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,7 @@
% layout 'default';
% stash side_menu => 1;
% content_for 'side_menu' => begin
<a href="/perfil">Mi perfil</a>
<a href="/perfil/configura-tu-avatar">Avatar</a>
% end
<%= content %>

View File

@ -0,0 +1,15 @@
% use MyRedland::Posts;
%
% layout 'default';
% title 'Login Redland Official';
<div class="description">
<h2>Página de Login.</h2>
<form action="/login" method="post">
Nombre de usuario
<input placeholder="Nombre de usuario" type="text" name="username"/>
Contraseña
<input placeholder="Contraseña" type="password" name="password"/>
<input type="submit" value="Enviar"/>
</form>
<p><small>¿No tienes cuenta? <a href="/registrate">Registrate</a></small></p>
</div>

View File

@ -0,0 +1,10 @@
% layout 'default';
% title 'Logout - Redland Official';
<div class="description">
<h2>¿Seguro que deseas cerrar la sesión?</h2>
<form action="/logout" method="post">
<input type="submit" value="Sí, deseo cerrar la sesión." name="yes"/>
<input type="submit" value="No, sacame de aquí." name="no"/>
</form>
</div>

View File

@ -0,0 +1,11 @@
% layout 'default';
% title 'Verifica el correo Redland Official';
<div class="description">
<h2>Verifica tu correo.</h2>
<p>Te hemos enviado un correo electrónico, pulsa sobre el enlace para verificar tu correo.</p>
<p>Recuerda que tendrás que estar logueado en la página para poder confirmar tu correo, nada más terminar el registro te logueamos automáticamente, pero es posible que al cerrar el navegador tu sesión sea terminada y tengas que loguearte de nuevo.</p>
<p>También es posible que quieras confirmar tu correo desde otro dispositivo, para ello tendrás que loguearte en ese otro dispositivo en redlandofficial.com, si no no podremos verificar que realmente eres tú quien pulsa el enlace, otra opción es reenviar el enlace al dispositivo desde el que te has registrado.</p>
<p>Si necesitas ayuda contacta con <a href="mailto:contact@owlcode.tech">contact@owlcode.tech</a>.</p>
</div>

View File

@ -0,0 +1,10 @@
% title 'Perfil - Redland Official';
% layout 'side_menu';
<div class="description">
<h2>Tu perfil.</h2>
<p>Tu nombre de usuario es <%= $self->current_user->username %></p>
<p>El estado de tu cuenta es <%= 'no' unless $self->current_user->verified %> verificado.</p>
<p><a href="/logout">Cerrar sesión</a></p>
</div>

View File

@ -0,0 +1,13 @@
% layout 'side_menu';
% title 'Configura tu avatar Redland Official';
<div class="description">
<form action="/perfil">
<h2>Configura tu avatar.</h2>
<input type="file" id="avatar-selector" accept=".jpg, .png, .jpeg .webp">
<div style="display: none;" id="avatar-cropper"></div>
<input type="submit" id="omit-avatar-setup" value="Omitir"/>
<button disabled type="button" id="select-avatar">Seleccionar</button>
</form>
<script src="/js/npm.bundle.js"></script>
</div>

View File

@ -0,0 +1,18 @@
% use MyRedland::Posts;
%
% layout 'default';
% title 'Login Redland Official';
<div class="description">
<h2>Página de Login.</h2>
<form action="/registrate" method="post">
Nombre de usuario
<input placeholder="Nombre de usuario" type="text" name="username"/>
Email
<input placeholder="Email" type="text" name="email"/>
Contraseña
<input placeholder="Contraseña" type="password" name="password"/>
Repite la contraseña
<input placeholder="Repite la contraseña" type="password" name="repeat_password"/>
<input type="submit" value="Enviar"/>
</form>
</div>

19
webpack.config.js Normal file
View File

@ -0,0 +1,19 @@
const path = require('path');
const devMode = process.env.NODE_ENV !== "production";
module.exports = {
devtool: "inline-source-map",
mode: 'development',
entry: path.resolve(__dirname, 'js/index.js'),
output: {
path: path.resolve(__dirname, 'public/js'),
filename: 'npm.bundle.js'
},
module: {
rules: [{
test: /\.(sa|sc|c)ss$/i,
use: [devMode ? "style-loader" : MiniCssExtractPlugin.loader, "css-loader", "postcss-loader", "sass-loader"]
}]
}
};