mirror of
https://github.com/mgerb/classic-wow-forums
synced 2026-01-09 00:42:47 +00:00
little bit of everything - it's been a long day
This commit is contained in:
BIN
client/app/assets/avatars/unknown.gif
Normal file
BIN
client/app/assets/avatars/unknown.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
client/app/assets/search.gif
Normal file
BIN
client/app/assets/search.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 347 B |
@@ -2,10 +2,10 @@
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: calc(100% - 96px);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
background-color: rgba(0, 0, 0, .9);
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
@@ -15,21 +15,22 @@
|
||||
flex-direction: column;
|
||||
padding-bottom: 40px;
|
||||
|
||||
&__character-count {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&__title {
|
||||
height: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__text-area {
|
||||
min-height: 100px;
|
||||
resize: none;
|
||||
margin-bottom: 20px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
min-height: 100px;
|
||||
background-color: #161616;
|
||||
border-radius: 2px;
|
||||
padding: 5px 10px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -5,15 +5,17 @@ import { ContentContainer } from '../content-container/content-container';
|
||||
import './editor.scss';
|
||||
|
||||
interface Props {
|
||||
categoryId: string;
|
||||
categoryId?: string;
|
||||
onClose: (cancel: boolean) => any;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
title: string;
|
||||
content: string;
|
||||
contentPreview: string;
|
||||
characterCount: number;
|
||||
contentCharacterCount: number;
|
||||
titleCharacterCount: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,8 @@ export class Editor extends React.Component<Props, State> {
|
||||
title: '',
|
||||
content: '',
|
||||
contentPreview: '',
|
||||
characterCount: 0,
|
||||
contentCharacterCount: 0,
|
||||
titleCharacterCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,12 +35,48 @@ export class Editor extends React.Component<Props, State> {
|
||||
this.setState({
|
||||
content: event.target.value,
|
||||
contentPreview: marked(event.target.value, { sanitize: true }),
|
||||
characterCount: event.target.value.length,
|
||||
contentCharacterCount: event.target.value.length,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
onTitleChange(event: any) {
|
||||
this.setState({
|
||||
title: event.target.value,
|
||||
titleCharacterCount: event.target.value.length,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmit(event: any) {
|
||||
event.preventDefault();
|
||||
if (this.props.threadId) {
|
||||
this.newReply();
|
||||
} else {
|
||||
this.newThread();
|
||||
}
|
||||
}
|
||||
|
||||
async newReply() {
|
||||
const { content } = this.state;
|
||||
|
||||
if (content === '') {
|
||||
this.setState({ errorMessage: 'Content must not be blank.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
content,
|
||||
thread_id: this.props.threadId,
|
||||
};
|
||||
|
||||
try {
|
||||
await axios.post('/api/reply', data);
|
||||
this.props.onClose(false);
|
||||
} catch (e) {
|
||||
this.setState({ errorMessage: 'Server error. Please try again later.' });
|
||||
}
|
||||
}
|
||||
|
||||
async newThread() {
|
||||
const { title, content } = this.state;
|
||||
|
||||
if (title === '' || content === '') {
|
||||
@@ -51,10 +90,6 @@ export class Editor extends React.Component<Props, State> {
|
||||
category_id: this.props.categoryId,
|
||||
};
|
||||
|
||||
this.newThread(data);
|
||||
}
|
||||
|
||||
async newThread(data: any) {
|
||||
try {
|
||||
await axios.post('/api/thread', data);
|
||||
this.props.onClose(false);
|
||||
@@ -63,25 +98,46 @@ export class Editor extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
renderThreadPortion() {
|
||||
return (
|
||||
<div className="flex flex--column">
|
||||
<h2 style={{ color: 'white' }}>New Topic</h2>
|
||||
<label>Title</label>
|
||||
<input type="text"
|
||||
className="input editor__title"
|
||||
onChange={event => this.onTitleChange(event)}
|
||||
maxLength={300}/>
|
||||
<div className="editor__character-count">{this.state.contentCharacterCount}/2000</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: quote
|
||||
renderReplyPortion() {
|
||||
return (
|
||||
<h2 style={{ color: 'white' }}>New Reply</h2>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<ContentContainer className="editor">
|
||||
<h2 style={{ color: 'white' }}>New Topic</h2>
|
||||
<label>Title</label>
|
||||
<input type="text" className="input editor__title" onChange={event => this.setState({ title: event.target.value })}/>
|
||||
<label>Content</label>
|
||||
<textarea className="input editor__text-area flex-1" onChange={event => this.onContentChange(event)}/>
|
||||
<label>Content Preview</label>
|
||||
<div className="editor__preview flex-1" dangerouslySetInnerHTML={{ __html: this.state.contentPreview }}></div>
|
||||
<div className="editor__submit">
|
||||
<a onClick={() => this.onSubmit()}>Submit</a>
|
||||
<a onClick={() => this.props.onClose(true)} style={{ marginLeft: '10px' }}>Cancel</a>
|
||||
<span className="editor__error-message">{this.state.errorMessage}</span>
|
||||
<span style={{ float: 'right' }}>{this.state.characterCount}/2000</span>
|
||||
</div>
|
||||
</ContentContainer>
|
||||
<form onSubmit={event => this.onSubmit(event)} onReset={() => this.props.onClose(true)}>
|
||||
<ContentContainer className="editor">
|
||||
{this.props.threadId ? this.renderReplyPortion() : this.renderThreadPortion()}
|
||||
<label>Content</label>
|
||||
<textarea className="input editor__text-area flex-1" onChange={event => this.onContentChange(event)} maxLength={2000}/>
|
||||
<div className="editor__character-count">{this.state.contentCharacterCount}/2000</div>
|
||||
<label>Preview</label>
|
||||
<div className="editor__preview flex-1" dangerouslySetInnerHTML={{ __html: this.state.contentPreview }}></div>
|
||||
<div className="editor__submit">
|
||||
<input type="submit" value="Submit" className="input__button"/>
|
||||
<input type="reset" value="Cancel" className="input__button" style={{ marginLeft: '10px' }}/>
|
||||
<span className="editor__error-message">{this.state.errorMessage}</span>
|
||||
</div>
|
||||
</ContentContainer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
6
client/app/components/login-button/login-button.scss
Normal file
6
client/app/components/login-button/login-button.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.portrait-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Portrait } from '../portrait/portrait';
|
||||
import { UserStore } from '../../stores/user-store';
|
||||
import { CharacterService } from '../../services';
|
||||
import { Oauth } from '../../util';
|
||||
import './login-button.scss';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -24,7 +25,7 @@ export class LoginButton extends React.Component<Props, State> {
|
||||
renderPortrait() {
|
||||
const avatarSrc = CharacterService.getAvatar(this.props.userStore!.user!.character_avatar!);
|
||||
return (
|
||||
<div style={{ padding: '10px' }}>
|
||||
<div className="portrait-container">
|
||||
<div onClick={() => this.props.onNavigate('/user-account')} style={{ cursor: 'pointer' }}>
|
||||
{avatarSrc && <Portrait imageSrc={avatarSrc}/>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface RepyModel {
|
||||
import { UserModel } from './user';
|
||||
|
||||
export interface ReplyModel {
|
||||
content: string;
|
||||
edited: boolean;
|
||||
id: number;
|
||||
@@ -7,4 +9,5 @@ export interface RepyModel {
|
||||
thread_id: number;
|
||||
updated_at: string;
|
||||
user_id: number;
|
||||
user: UserModel;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { RepyModel } from './reply';
|
||||
import { ReplyModel } from './reply';
|
||||
import { UserModel } from './user';
|
||||
|
||||
export interface ThreadModel {
|
||||
category_id: number;
|
||||
content: string;
|
||||
title: string;
|
||||
edited: boolean;
|
||||
id: number;
|
||||
inserted_at: string;
|
||||
last_reply: { id: number; battletag: string };
|
||||
last_reply: UserModel;
|
||||
last_reply_id: number;
|
||||
locked: boolean;
|
||||
replies: RepyModel[];
|
||||
replies: ReplyModel[];
|
||||
reply_count: number;
|
||||
sticky: boolean;
|
||||
updated_at: string;
|
||||
user: { id: number; battletag: string };
|
||||
user: UserModel;
|
||||
user_id: number;
|
||||
view_count: number;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
||||
import { get } from 'lodash';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { ThreadService } from '../../services';
|
||||
import { Editor, ForumNav, LoginButton, ScrollToTop } from '../../components';
|
||||
@@ -107,10 +106,13 @@ export class Forum extends React.Component<Props, State> {
|
||||
<div className={`forum-row ${index % 2 === 0 && 'forum-row--dark'}`} key={index}>
|
||||
{this.renderCell('flag', { maxWidth: '50px' })}
|
||||
{this.renderCell(<Link to={`/f/${categoryId}/${thread.id}`}>{thread.title}</Link>, { minWidth: '200px' })}
|
||||
{this.renderCell(<b>{thread.user.battletag}</b>, { maxWidth: '150px' })}
|
||||
{this.renderCell(<b>{thread.user.character_name || thread.user.battletag}</b>, { maxWidth: '150px' })}
|
||||
{this.renderCell(<b>{thread.reply_count}</b>, { maxWidth: '150px' }, true)}
|
||||
{this.renderCell(<b>{thread.view_count}</b>, { maxWidth: '150px' }, true)}
|
||||
{this.renderCell(<span>by <b>{get(thread, 'last_reply.battletag')}</b></span>, { maxWidth: '200px' })}
|
||||
{this.renderCell(
|
||||
<span>by <b>{thread.last_reply.character_name || thread.last_reply.battletag}</b></span>,
|
||||
{ maxWidth: '200px' },
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ export class Home extends React.Component<Props, State> {
|
||||
{this.renderTopic(139, 'Bug Report Forum', bugs, `Found a bug on this site? Help us squash it by reporting it here!`)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<hr className="hr"/>
|
||||
|
||||
<div className="topic-row topic-row__classes">
|
||||
<div className="topic-item topic-item__classes">
|
||||
@@ -193,7 +193,7 @@ export class Home extends React.Component<Props, State> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<hr className="hr"/>
|
||||
</ContentContainer>
|
||||
</ScrollToTop>
|
||||
);
|
||||
|
||||
@@ -46,8 +46,11 @@
|
||||
|
||||
.reply {
|
||||
display: flex;
|
||||
background-color: rgb(22, 22, 22);
|
||||
// border: 1px solid #343434;
|
||||
background-color: rgb(37, 37, 37);
|
||||
|
||||
&--dark {
|
||||
background-color: rgb(22, 22, 22);
|
||||
}
|
||||
|
||||
&__user-container {
|
||||
display: flex;
|
||||
@@ -89,3 +92,8 @@
|
||||
width: 100%;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
color: #FFAC04;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ import React from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { get, map } from 'lodash';
|
||||
import marked from 'marked';
|
||||
import { ThreadService } from '../../services';
|
||||
import { Portrait, ScrollToTop } from '../../components';
|
||||
import { ThreadModel } from '../../model';
|
||||
import { DateTime } from 'luxon';
|
||||
import { CharacterService, ThreadService } from '../../services';
|
||||
import { Editor, Portrait, ScrollToTop } from '../../components';
|
||||
import { ReplyModel, ThreadModel } from '../../model';
|
||||
import './thread.scss';
|
||||
|
||||
interface Props extends RouteComponentProps<any> {}
|
||||
|
||||
interface State {
|
||||
showEditor: boolean;
|
||||
thread?: ThreadModel;
|
||||
}
|
||||
|
||||
@@ -17,41 +19,89 @@ export class Thread extends React.Component<Props, State> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.state = {
|
||||
showEditor: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getThreads();
|
||||
this.getReplies();
|
||||
}
|
||||
|
||||
private async getThreads() {
|
||||
private async getReplies() {
|
||||
const thread = await ThreadService.getThread(this.props.match.params['threadId']);
|
||||
thread.replies = map([thread as any, ...thread.replies], (reply) => { // add the thread topic to the front of the list
|
||||
thread.replies = map(thread.replies, (reply) => { // add the thread topic to the front of the list
|
||||
reply.content = marked(reply.content, { sanitize: true });
|
||||
return reply;
|
||||
});
|
||||
this.setState({ thread });
|
||||
}
|
||||
|
||||
private onReplyClick() {
|
||||
this.setState({ showEditor: true });
|
||||
}
|
||||
|
||||
private onQuoteClick(reply: ReplyModel) {
|
||||
console.log(reply);
|
||||
}
|
||||
|
||||
private onEditorClose(cancel: boolean) {
|
||||
this.setState({ showEditor: false });
|
||||
if (!cancel) {
|
||||
this.getReplies();
|
||||
}
|
||||
}
|
||||
|
||||
private navigateForumIndex() {
|
||||
this.props.history.push(`/f/${this.state.thread!.category_id}`);
|
||||
}
|
||||
|
||||
private getTimeFormat(dateTime: string) {
|
||||
return DateTime.fromISO(dateTime).toLocaleString({
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
renderUserInfo(reply: ReplyModel) {
|
||||
const { battletag, character_avatar, character_class, character_guild, character_name, character_realm } = reply.user;
|
||||
return (
|
||||
<div className="reply__user-container">
|
||||
<Portrait imageSrc={CharacterService.getAvatar(character_avatar)}/>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="character-name">{character_name || battletag}</div>
|
||||
{character_class && <div><small>{character_class}</small></div>}
|
||||
{character_guild && <div><small><b>Guild: </b>{character_guild}</small></div>}
|
||||
{character_realm && <div><small><b>Realm: </b>{character_realm}</small></div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderReplies(): any {
|
||||
return this.state.thread!.replies.map((reply, index) => {
|
||||
const replyDark = index % 2 === 0 ? 'reply--dark' : '';
|
||||
return (
|
||||
<div className="reply-container" key={index}>
|
||||
<div className="reply">
|
||||
<div className="reply__user-container">
|
||||
<Portrait imageSrc={require('../../assets/Tyren.gif')}/>
|
||||
<div>Tyren</div>
|
||||
<div>Blizzard Poster</div>
|
||||
</div>
|
||||
<div className={`reply ${replyDark}`}>
|
||||
{this.renderUserInfo(reply)}
|
||||
<div className="flex-1">
|
||||
<div className="reply__title">
|
||||
<div>
|
||||
<b>{`${index + 1}. `}{this.state.thread!.title}</b>
|
||||
<small style={{ paddingLeft: '5px' }}>| {reply.inserted_at}</small>
|
||||
<b>{`${index + 1}. `}{index > 0 && 'Re: '}{this.state.thread!.title}</b>
|
||||
<small style={{ paddingLeft: '5px' }}>| {this.getTimeFormat(reply.inserted_at)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<img src={require('../../assets/quote-button.gif')} className="reply__title__button"/>
|
||||
<img src={require('../../assets/reply-button.gif')} className="reply__title__button"/>
|
||||
<img src={require('../../assets/quote-button.gif')}
|
||||
className="reply__title__button"
|
||||
onClick={() => this.onQuoteClick(reply)}/>
|
||||
<img src={require('../../assets/reply-button.gif')}
|
||||
className="reply__title__button"
|
||||
onClick={() => this.onReplyClick()}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="reply__content" dangerouslySetInnerHTML={{ __html: reply.content }}/>
|
||||
@@ -62,22 +112,23 @@ export class Thread extends React.Component<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
private navigateForumIndex() {
|
||||
this.props.history.push(`/f/${this.state.thread!.category_id}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
if (!this.state.thread) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
const replies = get(this.state, 'thread.replies');
|
||||
|
||||
return (
|
||||
<ScrollToTop {...this.props}>
|
||||
|
||||
{this.state.showEditor && <Editor threadId={this.props.match.params['threadId']} onClose={cancel => this.onEditorClose(cancel)}/>}
|
||||
<div className="topic-bg">
|
||||
<div className="threadTopic-container">
|
||||
<div className="threadTopic">
|
||||
<img src={require('../../assets/sticky.gif')} style={{ marginRight: '5px' }}/>
|
||||
<b>Topic: </b><small style={{ paddingLeft: '15px', color: 'white' }}>| 12/20/2005 1:11:44 AM PST</small>
|
||||
<b>Topic: </b>
|
||||
<small style={{ paddingLeft: '15px', color: 'white' }}>| {this.getTimeFormat(this.state.thread!.inserted_at)}</small>
|
||||
</div>
|
||||
</div>
|
||||
<img src={require('../../assets/forum-index.gif')}
|
||||
|
||||
@@ -177,7 +177,7 @@ export class UserAccount extends React.Component<Props, State> {
|
||||
return (
|
||||
<ScrollToTop>
|
||||
<ContentContainer style={{ minHeight: '500px', paddingTop: '40px' }}>
|
||||
<div className="flex">
|
||||
<div className="flex" style={{ marginBottom: '20px' }}>
|
||||
{character_avatar && <Portrait imageSrc={CharacterService.getAvatar(character_avatar!)}/>}
|
||||
<div style={{ paddingLeft: '10px' }}>
|
||||
{battletag && <div><b>Battletag: </b>{battletag}</div>}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
$fontPrimary: #cccccc;
|
||||
$linkColor: #FFB019;
|
||||
|
||||
html {
|
||||
font-family: Arial,Helvetica,Sans-Serif;
|
||||
@@ -19,7 +20,7 @@ body {
|
||||
}
|
||||
|
||||
a {
|
||||
color: #FFB019;
|
||||
color: $linkColor;
|
||||
font-weight: bold;
|
||||
font-size: 9pt;
|
||||
text-decoration: underline;
|
||||
@@ -35,6 +36,20 @@ a {
|
||||
}
|
||||
|
||||
hr {
|
||||
size: 1;
|
||||
background-color: #9E9E9E;
|
||||
display: block;
|
||||
unicode-bidi: isolate;
|
||||
-webkit-margin-before: 0.5em;
|
||||
-webkit-margin-after: 0.5em;
|
||||
-webkit-margin-start: auto;
|
||||
-webkit-margin-end: auto;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.hr {
|
||||
background: rgb(71, 71, 71);
|
||||
width: 80%;
|
||||
height: 0.5px;
|
||||
@@ -44,6 +59,10 @@ hr {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -58,6 +77,10 @@ hr {
|
||||
&--wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&--column {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
@@ -89,4 +112,18 @@ span.grey {
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__button {
|
||||
background: none;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
color: $linkColor;
|
||||
font-weight: bold;
|
||||
font-size: 9pt;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { find, filter, get } from 'lodash';
|
||||
import { AvatarModel } from '../model';
|
||||
|
||||
const getAvatar = (title: string): any => {
|
||||
const getAvatar = (title?: string): any => {
|
||||
const av = find(avatarList, { title });
|
||||
return get(av, 'imageSrc');
|
||||
return get(av, 'imageSrc') || avatarList[0].imageSrc;
|
||||
};
|
||||
|
||||
const getClass = (index: number): {id: number, name: string; races: number[] } => {
|
||||
@@ -66,6 +66,11 @@ const classList = [
|
||||
];
|
||||
|
||||
const avatarList: AvatarModel[] = [
|
||||
{
|
||||
raceId: 0,
|
||||
title: 'unknown',
|
||||
imageSrc: require('../assets/avatars/unknown.gif'),
|
||||
},
|
||||
{
|
||||
raceId: 1,
|
||||
title: 'dwarf_f',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@types/fingerprintjs2": "^1.5.1",
|
||||
"@types/lodash": "^4.14.92",
|
||||
"@types/luxon": "^0.2.2",
|
||||
"@types/marked": "^0.3.0",
|
||||
"@types/node": "^9.3.0",
|
||||
"@types/query-string": "^5.0.1",
|
||||
@@ -38,6 +39,7 @@
|
||||
"font-awesome": "^4.7.0",
|
||||
"html-webpack-plugin": "^2.30.1",
|
||||
"lodash": "^4.17.4",
|
||||
"luxon": "^0.3.1",
|
||||
"marked": "^0.3.12",
|
||||
"mobx": "^3.4.1",
|
||||
"mobx-react": "^4.3.5",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
version "4.14.92"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.92.tgz#6e3cb0b71a1e12180a47a42a744e856c3ae99a57"
|
||||
|
||||
"@types/luxon@^0.2.2":
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-0.2.2.tgz#ca83c1026980b111254ef78587ee027483e57df4"
|
||||
|
||||
"@types/marked@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.3.0.tgz#583c223dd33385a1dda01aaf77b0cd0411c4b524"
|
||||
@@ -3665,6 +3669,10 @@ lru-cache@^4.0.1:
|
||||
pseudomap "^1.0.2"
|
||||
yallist "^2.1.2"
|
||||
|
||||
luxon@^0.3.1:
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/luxon/-/luxon-0.3.1.tgz#627f78f5fea94ac19e1cf2900f398a9338422bbd"
|
||||
|
||||
macaddress@^0.2.8:
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12"
|
||||
|
||||
@@ -12,12 +12,13 @@ defmodule MyApp.Data.Reply do
|
||||
field :content, :string
|
||||
field :edited, :boolean, default: false
|
||||
field :quote_id, :integer
|
||||
timestamps()
|
||||
has_one :user, Data.User, foreign_key: :id, references: :user_id
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
defp insert_changeset(reply, params \\ %{}) do
|
||||
reply
|
||||
|> cast(params, [:user_id, :thread_id, :content, :quote])
|
||||
|> cast(params, [:user_id, :thread_id, :content, :quote_id])
|
||||
|> validate_required([:user_id, :thread_id, :content])
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> foreign_key_constraint(:thread_id)
|
||||
@@ -33,10 +34,11 @@ defmodule MyApp.Data.Reply do
|
||||
|
||||
@spec insert(map) :: {:ok, map} | {:error, map}
|
||||
def insert(params) do
|
||||
insert_changeset(%Data.Reply{}, params)
|
||||
{:ok, data} = insert_changeset(%Data.Reply{}, params)
|
||||
|> Repo.insert
|
||||
|> Data.Util.process_insert_or_update
|
||||
|> update_thread_new_reply
|
||||
{:ok, Map.drop(data, [:user])} # drop user because we can't encode it if it's not preloaded
|
||||
end
|
||||
|
||||
defp update_thread_new_reply({:error, error}), do: {:error, error}
|
||||
|
||||
@@ -15,11 +15,11 @@ defmodule MyApp.Data.Thread do
|
||||
field :sticky, :boolean, default: false
|
||||
field :locked, :boolean, default: false
|
||||
field :edited, :boolean, default: false
|
||||
field :reply_count, :integer, default: 1
|
||||
field :reply_count, :integer, default: 0
|
||||
has_many :replies, Data.Reply
|
||||
has_one :user, Data.User, foreign_key: :id, references: :user_id
|
||||
has_one :last_reply, Data.User, foreign_key: :id, references: :last_reply_id
|
||||
timestamps()
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
defp insert_changeset(thread, params \\ %{}) do
|
||||
@@ -39,7 +39,7 @@ defmodule MyApp.Data.Thread do
|
||||
def get(thread_id) do
|
||||
query = from t in Data.Thread,
|
||||
where: t.id == ^thread_id,
|
||||
preload: [:user, :last_reply, :replies]
|
||||
preload: [:user, :last_reply, replies: :user]
|
||||
|
||||
Repo.one(query)
|
||||
|> process_get
|
||||
@@ -79,18 +79,20 @@ defmodule MyApp.Data.Thread do
|
||||
|
||||
@spec insert(map) :: {:ok, map} | {:error, map}
|
||||
def insert(params) do
|
||||
Repo.transaction(fn ->
|
||||
{_, data} = Repo.transaction(fn ->
|
||||
params = Map.put(params, "last_reply_id", Map.get(params, "user_id"))
|
||||
{:ok, thread} = insert_changeset(%Data.Thread{}, params)
|
||||
|> Repo.insert
|
||||
|
||||
{:ok, _} = Repo.insert(%Data.Reply{
|
||||
{:ok, data} = Repo.insert(%Data.Reply{
|
||||
thread_id: Map.get(thread, :id),
|
||||
content: Map.get(params, "content"),
|
||||
user_id: Map.get(params, "user_id")
|
||||
})
|
||||
"ok"
|
||||
# return the new thread we inserted - drop associations because we don't load them
|
||||
{:ok, Map.drop(thread, [:last_reply, :replies, :user])}
|
||||
end)
|
||||
data
|
||||
end
|
||||
|
||||
# this doesn't update the 'updated_at' field which is what we want
|
||||
|
||||
@@ -15,7 +15,7 @@ defmodule MyApp.Data.User do
|
||||
field :character_class, :string
|
||||
field :character_realm, :string
|
||||
field :character_avatar, :string
|
||||
timestamps()
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
defp changeset(user, params \\ %{}) do
|
||||
|
||||
@@ -3,7 +3,7 @@ defmodule MyApp.Repo.Migrations.CreateThread do
|
||||
|
||||
def change do
|
||||
create table(:thread) do
|
||||
add :title, :string
|
||||
add :title, :string, size: 300
|
||||
add :category_id, :integer
|
||||
add :view_count, :integer
|
||||
add :user_id, references(:user)
|
||||
|
||||
@@ -13,18 +13,19 @@ defmodule MyApp.Data.ThreadTest do
|
||||
end
|
||||
|
||||
test "insert: try to insert with no parameters" do
|
||||
assert insert(%{}) == {:error,
|
||||
[%{title: "can't be blank"}, %{category_id: "can't be blank"},
|
||||
%{content: "can't be blank"}, %{user_id: "can't be blank"}]}
|
||||
{error, _} = catch_error(insert(%{}))
|
||||
assert error == :badmatch
|
||||
end
|
||||
|
||||
test "insert: insert as invalid user" do
|
||||
assert insert(new_thread(9238748)) == {:error, [%{user_id: "does not exist"}]}
|
||||
{:badmatch, {:error, data}} = catch_error(insert(new_thread(9238748)))
|
||||
assert data.errors == [user_id: {"does not exist", []}]
|
||||
end
|
||||
|
||||
test "insert: insert as invalid category_id" do
|
||||
{:ok, user} = new_user()
|
||||
assert insert(new_thread(user.id, 2342342343)) == {:error, [%{category_id: "does not exist"}]}
|
||||
{error, _} = catch_error(insert(new_thread(user.id, 2342342343)))
|
||||
assert error == :badmatch
|
||||
end
|
||||
|
||||
test "new thread should be inserted" do
|
||||
@@ -33,7 +34,6 @@ defmodule MyApp.Data.ThreadTest do
|
||||
assert thread.title == "test title"
|
||||
assert thread.category_id == 1
|
||||
assert thread.user_id == user.id
|
||||
assert thread.content == "test content"
|
||||
end
|
||||
|
||||
# TODO: update thread
|
||||
|
||||
@@ -41,6 +41,11 @@ defmodule MyApp.Data.UserTest do
|
||||
battletag: "mgerb",
|
||||
id: user.id,
|
||||
permissions: "user",
|
||||
character_avatar: nil,
|
||||
character_class: nil,
|
||||
character_guild: nil,
|
||||
character_name: nil,
|
||||
character_realm: nil,
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
@@ -7,21 +7,14 @@ defmodule MyAppWeb.ReplyControllerTest do
|
||||
new_conn = build_conn()
|
||||
|> put_req_header("authorization", "Bearer " <> user.token)
|
||||
|
||||
conn = post(new_conn, "/api/reply")
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
{:badmatch, {:error, data}} = conn = catch_error(post(new_conn, "/api/reply"))
|
||||
assert data == [%{thread_id: "can't be blank"}, %{content: "can't be blank"}]
|
||||
|
||||
assert body["error"]["message"] == [
|
||||
%{"thread_id" => "can't be blank"},
|
||||
%{"content" => "can't be blank"},
|
||||
]
|
||||
{:badmatch, {:error, data}} = conn = catch_error(post(new_conn, "/api/reply", %{"content" => "t"}))
|
||||
assert data == [%{thread_id: "can't be blank"}]
|
||||
|
||||
conn = post(new_conn, "/api/reply", %{"content" => "t"})
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
assert body["error"]["message"] == [%{"thread_id" => "can't be blank"}]
|
||||
|
||||
conn = post(new_conn, "/api/reply", %{"content" => "t", "thread_id" => 1})
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
assert body["error"]["message"] == [%{"thread_id" => "does not exist"}]
|
||||
{:badmatch, {:error, data}} = conn = catch_error(post(new_conn, "/api/reply", %{"content" => "t", "thread_id" => 1}))
|
||||
assert data == [%{thread_id: "does not exist"}]
|
||||
end
|
||||
|
||||
test "insert new reply should succeed" do
|
||||
@@ -30,7 +23,7 @@ defmodule MyAppWeb.ReplyControllerTest do
|
||||
|> put_req_header("authorization", "Bearer " <> user.token)
|
||||
|
||||
# insert new thread first
|
||||
conn = post(new_conn, "/api/thread", %{"title" => "t", "category_id" => 1, "content" => "t"})
|
||||
conn = post(new_conn, "/api/thread", %{"title" => "t", "category_id" => 1})
|
||||
body = conn |> response(200) |> Poison.decode!
|
||||
|
||||
conn = post(new_conn, "/api/reply", %{"content" => "c", "thread_id" => body["data"]["id"]})
|
||||
@@ -41,15 +34,13 @@ defmodule MyAppWeb.ReplyControllerTest do
|
||||
|
||||
assert data["content"] == "c"
|
||||
assert data["edited"] == false
|
||||
assert data["quote"] == false
|
||||
assert data["quote_id"] == nil
|
||||
|
||||
# make sure thread reply count and last reply id are updated
|
||||
conn = get(new_conn, "/api/thread?category_id=1")
|
||||
body = conn |> response(200) |> Poison.decode!
|
||||
# conn = get(new_conn, "/api/thread?category_id=1")
|
||||
# body = conn |> response(200) |> Poison.decode!
|
||||
|
||||
data = Enum.at(body["data"], 0)
|
||||
assert data["reply_count"] == 1
|
||||
assert data["last_reply_id"] == user_id
|
||||
# assert Enum.at(body["data"], 0) == "ok"
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -7,29 +7,22 @@ defmodule MyAppWeb.ThreadControllerTest do
|
||||
new_conn = build_conn()
|
||||
|> put_req_header("authorization", "Bearer " <> user.token)
|
||||
|
||||
conn = post(new_conn, "/api/thread")
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
{:badmatch, {:error, data}} = catch_error(post(new_conn, "/api/thread"))
|
||||
|
||||
assert body["error"]["message"] == [
|
||||
%{"title" => "can't be blank"},
|
||||
%{"category_id" => "can't be blank"},
|
||||
%{"content" => "can't be blank"},
|
||||
assert data.errors == [
|
||||
title: {"can't be blank", [validation: :required]},
|
||||
category_id: {"can't be blank", [validation: :required]},
|
||||
]
|
||||
|
||||
conn = post(new_conn, "/api/thread", %{"title" => "t"})
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
assert body["error"]["message"] == [
|
||||
%{"category_id" => "can't be blank"},
|
||||
%{"content" => "can't be blank"},
|
||||
{:badmatch, {:error, data}} = catch_error(post(new_conn, "/api/thread", %{"title" => "t"}))
|
||||
|
||||
assert data.errors == [
|
||||
category_id: {"can't be blank", [validation: :required]},
|
||||
]
|
||||
|
||||
conn = post(new_conn, "/api/thread", %{"title" => "t", "category_id" => 1})
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
assert body["error"]["message"] == [%{"content" => "can't be blank"}]
|
||||
{:badmatch, {:error, data}} = catch_error(post(new_conn, "/api/thread", %{"title" => "t", "category_id" => 100000}))
|
||||
|
||||
conn = post(new_conn, "/api/thread", %{"title" => "t", "category_id" => 100000, "content" => "t"})
|
||||
body = conn |> response(400) |> Poison.decode!
|
||||
assert body["error"]["message"] == [%{"category_id" => "does not exist"}]
|
||||
assert data.errors == [category_id: {"does not exist", []}]
|
||||
end
|
||||
|
||||
test "insert new thread should succeed" do
|
||||
@@ -40,10 +33,10 @@ defmodule MyAppWeb.ThreadControllerTest do
|
||||
body = conn |> response(200) |> Poison.decode!
|
||||
|
||||
data = body["data"]
|
||||
# assert body == "test"
|
||||
assert data["user_id"] == user.id
|
||||
assert data["category_id"] == 1
|
||||
assert data["title"] == "t"
|
||||
assert data["content"] == "t"
|
||||
end
|
||||
|
||||
# TODO: update thread / delete thread
|
||||
|
||||
Reference in New Issue
Block a user