Kaynağa Gözat

Merge branch 'admin'

sbkwgh 8 yıl önce
ebeveyn
işleme
d654326e05

+ 1 - 0
package.json

@@ -10,6 +10,7 @@
   },
   "dependencies": {
     "axios": "^0.15.3",
+    "d3": "^4.9.1",
     "highlight.js": "^9.10.0",
     "lodash.throttle": "^4.1.1",
     "marked": "^0.3.6",

+ 24 - 0
src/assets/scss/variables.scss

@@ -56,6 +56,30 @@ $color__lightblue--primary: #03A9F4;
 	animation-timing-function: linear;
 }
 
+@mixin loading-overlay($background-color: #fff, $border-radius: 0.25rem) {
+	width: 100%;
+	height: 100%;
+	position: absolute;
+	background-color: $background-color;
+	z-index: 1;
+	top: 0;
+	position: absolute;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	opacity: 0;
+	pointer-events: none;
+	transition: all 0.2s;
+	@include user-select(none);
+	cursor: default;
+	border-radius: $border-radius;
+
+	@at-root #{&}--show {
+		opacity: 1;
+		pointer-events: all;
+	}
+}
+
 @mixin text($family: $font--role-default, $size: 1rem, $weight: 300) {
 	font-family: $family;
 	font-size: $size;

+ 122 - 0
src/components/routes/Admin.vue

@@ -0,0 +1,122 @@
+<template>
+	<div class='admin'>
+		<div class='admin__menu'>
+			<div 
+				class='admin__menu__item'
+				v-for='route in routes'
+				:class='{ "admin__menu__item--selected" : route.route === selected }'
+				@click='$router.push("/admin/" + route.route)'
+			>
+				<div>
+					<span class='fa admin__menu__item__icon' :class='route.icon'></span>
+				</div>
+				<div>
+					<div class='admin__menu__item__title'>
+						{{route.title}}
+					</div>
+					<div class='admin__menu__item__description'>
+						{{route.description}}
+					</div>
+				</div>
+			</div>
+		</div>
+		<router-view class='admin__router_view'></router-view>
+	</div>
+</template>
+
+<script>
+	export default {
+		name: 'Admin',
+		data () {
+			return {
+				selected: null,
+				routes: [
+					{ title: 'Dashboard', route: 'dashboard', description: 'Quick links and stats about your forum', icon: 'fa-home' },
+					{ title: 'Moderation', route: 'moderation', description: 'View and respond to user reports', icon: 'fa-exclamation-circle' },
+					{ title: 'Categories', route: 'categories', description: 'Add and remove thread categories', icon: 'fa-th' },
+					{ title: 'Back-up', route: 'backup', description: 'Download and restore forum data', icon: 'fa-cloud-download' }
+				]
+			}
+		},
+		watch: {
+			$route (to, from) {
+				this.selected = to.path.split('/')[2]
+			}
+		},
+		created () {
+			this.selected = this.$route.path.split('/')[2]
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	@import '../../assets/scss/variables.scss';
+
+	.admin {
+		height: calc(100% + 1rem);
+		margin-top: -1rem;
+		display: flex;
+		flex-direction: row;
+
+		@at-root #{&}__menu {
+			width: 15rem;
+			height: calc(100%);
+			background-color: #fff;
+			cursor: default;
+			overflow-y: auto;
+			border-right: thin solid $color__lightgray--darker;
+
+			@at-root #{&}__item {
+				transition: background-color 0.2s;
+				padding: 1rem;
+				border-bottom: thin solid $color__lightgray--darker;
+				display: flex;
+				flex-direction: row;
+				position: relative;
+
+				&:hover {
+					background-color: $color__lightgray--primary;
+				}
+
+				&::before {
+					content: '';
+					position: absolute;
+					left: -0.25rem;
+					top: 0;
+					width: 0.25rem;
+					height: 100%;
+					background-color: $color__gray--darkest;
+					transition: left 0.2s;
+				}
+
+				@at-root #{&}--selected {
+					background-color: $color__lightgray--primary;
+
+					&::before {
+						left: 0;
+					}
+				}
+
+				@at-root #{&}__icon {
+					margin-right: 0.5rem;
+					margin-top: 0.1875rem;
+				}
+
+				@at-root #{&}__title {
+					font-weight: 600;
+				}
+				@at-root #{&}__description {
+					font-size: 0.9rem;
+					color: $color__text--secondary;
+					margin-top: 0.125rem;
+				}
+			}
+		}
+
+		@at-root #{&}__router_view {
+			width: 100%;
+			height: 100%;
+			overflow-y: auto;
+		}
+	}
+</style>

+ 91 - 0
src/components/routes/AdminDashboard.vue

@@ -0,0 +1,91 @@
+<template>
+	<div class='admin_dashboard'>
+		<div class='admin_dashboard__row'>
+			<div class='admin_dashboard__card admin_dashboard__card--3'>
+				<line-chart background='#f39c12' point='rgb(255, 237, 127)'></line-chart>
+				<div class='admin_dashboard__card__title'>Page views over the past week</div>
+			</div>
+			<div class='admin_dashboard__card admin_dashboard__card--2'>
+				<new-posts></new-posts>
+				<div class='admin_dashboard__card__title'>New posts in the last 24 hours</div>
+			</div>
+			<div class='admin_dashboard__card admin_dashboard__card--2'>
+				<categories-chart></categories-chart>
+				<div class='admin_dashboard__card__title'>Number of threads by category</div>
+			</div>
+		</div>
+		<div class='admin_dashboard__row'>
+			<div class='admin_dashboard__card admin_dashboard__card--2'>
+				<top-posts></top-posts>
+				<div class='admin_dashboard__card__title'>Top threads by page views today</div>
+			</div>
+			<div class='admin_dashboard__card admin_dashboard__card--3'>
+				<line-chart background='#84dec0' point='#1da8ce'></line-chart>
+				<div class='admin_dashboard__card__title'>New users over the past week</div>
+			</div>
+			<div class='admin_dashboard__card admin_dashboard__card--2 admin_dashboard__card--hidden'></div>
+		</div>
+
+	</div>
+</template>
+
+<script>
+	import NewPosts from '../widgets/NewPosts'
+	import LineChart from '../widgets/LineChart'
+	import CategoriesChart from '../widgets/CategoriesChart'
+	import TopPosts from '../widgets/TopPosts'
+
+	export default {
+		name: 'AdminDashboard',
+		components: {
+			NewPosts,
+			LineChart,
+			CategoriesChart,
+			TopPosts
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	@import '../../assets/scss/variables.scss';
+
+	.admin_dashboard {
+		padding: 1rem;
+
+		@at-root #{&}__row {
+			display: flex;
+		}
+
+		@at-root #{&}__card {
+			margin: 1rem;
+			height: 12rem;
+			background-color: #fff;
+			border-radius: 0.25rem;
+			flex: 1;
+			display: flex;
+			flex-direction: column;
+
+			@extend .shadow_border;
+
+			@for $i from 1 through 5 {
+				@at-root #{&}--#{$i} {
+					flex: $i;
+				}
+			}
+
+			@at-root #{&}--hidden {
+				visibility: hidden;
+			}
+
+			@at-root #{&}__title {
+				background-color: $color__gray--primary;
+				width: 100%;
+				padding: 0.25rem 0.35rem;
+				box-shadow: 0 0.1rem 0.075rem rgba(175, 175, 175, 0.25);
+				border-radius: 0 0 0.25rem 0.25rem;
+				cursor: default;
+				font-size: 0.9rem;
+			}
+		}
+	}
+</style>

+ 15 - 0
src/components/routes/AdminModeration.vue

@@ -0,0 +1,15 @@
+<template>
+	<div>
+		Moderation
+	</div>
+</template>
+
+<script>
+	export default {
+		name: 'AdminDashboard'
+	}
+</script>
+
+<style lang='scss' scoped>
+	
+</style>

+ 207 - 0
src/components/widgets/CategoriesChart.vue

@@ -0,0 +1,207 @@
+<template>
+	<div class='widgets__categories_chart' ref='container'>
+		<div class='widgets__categories_chart__overlay' :class='{ "widgets__categories_chart__overlay--show" : loading }'>
+			<loading-icon></loading-icon>
+		</div>
+		<div
+			class='widgets__categories_chart__tooltip'
+			:class='{ "widgets__categories_chart__tooltip--show": tooltipShow }'
+			:style='{ "left": tooltipX, "top": tooltipY }'
+		>
+		</div>
+		<div class='widgets__categories_chart__main'>
+			<svg>
+				<g ref='g'></g>
+			</svg>
+			<div class='widgets__categories_chart__main__legend'>
+				<div
+					v-for='(category, $index) in data'
+					class='widgets__categories_chart__label'
+					@mouseover='toggleLabelHover($index)'
+					@mouseout='toggleLabelHover($index)'
+				>
+					<div class='widgets__categories_chart__label__square' :style="{ 'background-color': category.color }"></div>
+					{{category.label}}
+				</div>
+			</div>
+		</div>
+
+	</div>
+</template>
+
+<script>
+	import LoadingIcon from '../LoadingIcon'
+
+	import * as d3 from 'd3'
+	import throttle from 'lodash.throttle'
+
+	export default {
+		name: 'CategoriesChart',
+		components: { LoadingIcon },
+		data () {
+			let data_ = [
+				{ label: 'category 1', value: 0 },
+				{ label: 'category 2', value: 2 },
+				{ label: 'category 3', value: 3 },
+				{ label: 'category 4', value: 4 },
+				{ label: 'category 5', value: 5 }
+			]
+
+			let colors = d3
+				.scaleLinear()
+				.domain([0, data_.length])
+				.interpolate(d3.interpolateHcl)
+				.range(['#415f9c', '#4bd9ff'])
+
+			return {
+				loading: true,
+				padding: 20,
+				
+				tooltipX: 0,
+				tooltipY: 0,
+				tooltipShow: false,
+				tooltipItem: 0,
+
+				colors,
+				data_
+			}
+		},
+		computed: {
+			data () {
+				return this.data_.map((d, i) => {
+					d.color = this.colors(i)
+
+					return d
+				})
+			}
+		},
+		methods: {
+			updateFuncs () {
+				let height = this.$refs.container.getBoundingClientRect().height
+
+				let paddedHeight = (height - this.padding) / 2
+				let translate = paddedHeight + this.padding / 2
+
+				let pieSegments = d3.pie()(this.data.map(d => d.value))
+				let arcGenerator = d3.arc()
+					.innerRadius(paddedHeight - 40)
+					.outerRadius(paddedHeight)
+					.padAngle(Math.PI*2 * 2/360)
+
+				let g = d3.select(this.$refs.g).attr('transform', `translate(${translate}, ${translate})`)
+
+				let arcs = g.selectAll('path')
+					.data(pieSegments)
+					.enter()
+					.append('path')
+					.attr('d', arcGenerator)
+					.attr('data-index', (d, i) => i)
+					.attr('fill', (d, i) => this.colors(i))
+
+				let labels = g.selectAll('text')
+					.data(pieSegments)
+					.enter()
+					.append('text')
+					.text(d => d.value ? d.value : '')
+					.attr('data-index', (d, i) => i)
+					.attr('fill', '#fff')
+					.attr('transform', d => {
+						d.innerRadius = paddedHeight - 40
+						d.outerRadius = paddedHeight
+		
+						
+						let coords = arcGenerator.centroid(d)
+							.map((val, i) => i ? val+5 : val-5)
+							.join(',')
+
+						return `translate(${coords})`
+					})
+			},
+			toggleLabelHover (index) {
+				let g = this.$refs.g
+				let path = g.querySelector('path[data-index="' + index + '"]')
+				let text = g.querySelector('text[data-index="' + index + '"]')
+				let textTransform = text.getAttribute('transform')
+
+				path.classList.toggle('widgets__categories_chart__main--large')
+				
+				if(textTransform.includes('scale')) {
+					text.setAttribute('transform', textTransform.split(' ')[0])
+				} else {
+					text.setAttribute('transform', textTransform + ' scale(1.15)')
+				}
+			}
+		},
+		mounted () {
+			this.updateFuncs()
+
+			let resizeCb = throttle(() => {
+				this.updateFuncs()
+			}, 200)
+			window.addEventListener('resize', resizeCb)
+
+			setTimeout(() => {
+				this.loading = false;
+			}, Math.random()*3000)
+		}
+	}
+</script>
+
+<style lang='scss'>
+	@import '../../assets/scss/variables.scss';
+
+	.widgets__categories_chart {
+		background-color: rgba(225, 245, 254, 0.5);
+		width: 100%;
+		height: 100%;
+		overflow: hidden;
+		border-radius: 0.25rem 0.25rem 0 0;
+		position: relative;
+
+		@at-root #{&}__overlay {
+			@include loading-overlay(rgb(225, 245, 254), 0.25rem 0.25rem 0 0);
+		}
+
+		@at-root #{&}__main {
+			display: flex;
+			flex-direction: row;
+			height: 100%;
+			
+			svg {
+				height: 100%;
+				width: 11rem;
+	
+				path, text {
+					transition: all 0.2s;
+				}
+			}
+			@at-root #{&}--large {
+				transform: scale(1.075);
+			}
+			@at-root #{&}__legend {
+				padding: 10px 0;
+			}
+		}
+
+		@at-root #{&}__label {
+			position: relative;
+			cursor: default;
+			margin-left: 1rem;
+
+		
+		 	&:hover {
+				text-decoration: underline;
+			}
+
+			@at-root #{&}__square {
+				position: absolute;
+				top: 0.375rem;
+				left: -1.25rem;
+				height: 0.75rem;
+				width: 0.75rem;
+				border-radius: 0.125rem;
+			}
+		}
+
+	}
+</style>

+ 211 - 0
src/components/widgets/LineChart.vue

@@ -0,0 +1,211 @@
+<template>
+	<div class='widgets__line_chart' ref='container' :style='{ "background-color": background }'>
+		<div class='widgets__line_chart__overlay' :style='{ "background-color": background }'' :class='{ "widgets__line_chart__overlay--show" : loading }'>
+			<loading-icon></loading-icon>
+		</div>
+		<div
+			class='widgets__line_chart__tooltip'
+			:class='{ "widgets__line_chart__tooltip--show": tooltipShow }'
+			:style='{ "left": tooltipX, "top": tooltipY }'
+		>
+			{{data[tooltipItem].pageViews}} {{data[tooltipItem] | pluralize('page view') }}
+		</div>
+		<svg>
+			<g
+				ref='y_axis'
+				class='widgets__line_chart__axis'
+				:transform='"translate(" + 3*padding + ",0)"'
+			></g>
+			<g
+				ref='x_axis'
+				class='widgets__line_chart__axis widgets__line_chart__axis--x'
+				:transform='"translate(0,150)"'
+			></g>
+			<path :d='linePath' fill='none' stroke-width='2' stroke='#fff'></path>
+			<circle
+				v-for='circle in circles'
+				:cx='circle.x'
+				:cy='circle.y'
+				r='4'
+				:fill='point'
+			>
+			</circle>
+			<circle
+				v-for='(circle, $index) in circles'
+				:cx='circle.x'
+				:cy='circle.y'
+				r='10'
+				fill='rgba(0, 0, 0, 0)'
+
+				@mousemove='showTooltip($event, $index)'
+				@mouseout='hideTooltip'
+			>
+			</circle>
+		</svg>
+	</div>
+</template>
+
+<script>
+	import LoadingIcon from '../LoadingIcon'
+
+	import * as d3 from 'd3'
+	import throttle from 'lodash.throttle'
+
+	export default {
+		name: 'LineChart',
+		props: ['point', 'background'],
+		components: { LoadingIcon },
+		data () {
+			let data = [
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 5, 26) },
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 5, 27) },
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 5, 28) },
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 5, 29) },
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 5, 30) },
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 6, 1) },
+				{ pageViews: Math.floor(Math.random() * 100), date: new Date(2017, 6, 2) }
+			]
+
+			let x = d3
+				.scaleTime()
+				.domain([0, 0])
+				.range([0, 0])
+
+
+			let y = d3
+				.scaleLinear()
+				.domain([0, 0])
+				.range([0, 0])
+
+			return {
+				loading: true,
+				padding: 10,
+				
+				tooltipX: 0,
+				tooltipY: 0,
+				tooltipShow: false,
+				tooltipItem: 0,
+
+				data, x, y,
+			}
+		},
+		methods: {
+			setXFunc () {
+				let width = this.$refs.container.getBoundingClientRect().width - this.padding*2
+
+				this.x = d3
+					.scaleTime()
+					.domain([this.data[0].date, this.data.slice(-1)[0].date])
+					.range([this.padding * 4, width])
+			},
+			setYFunc () {
+				let height = this.$refs.container.getBoundingClientRect().height - this.padding*2
+
+				this.y = d3
+					.scaleLinear()
+					.domain([d3.max(this.data.map(d => d.pageViews)), 0])
+					.range([this.padding*1.5, height - this.padding/2])
+			},
+			updateFuncs () {
+				this.setXFunc()
+				this.setYFunc()
+			
+				d3.select(this.$refs.y_axis).call(d3.axisLeft(this.y.nice()))
+				d3.select(this.$refs.x_axis).call(d3.axisBottom(this.x).tickSize(0).ticks(this.data.length))
+			},
+			showTooltip (e, i) {
+				this.tooltipShow = true
+				this.tooltipX = e.clientX + 'px'
+				this.tooltipY = e.clientY - 30 + 'px'
+				this.tooltipItem = i
+			},
+			hideTooltip () {
+				this.tooltipShow = false
+			}
+		},
+		computed: {
+			linePath () {
+				let line = 	d3
+					.line()
+					.curve(d3.curveCatmullRom)
+					.x(d => this.x(d.date))
+					.y(d => this.y(d.pageViews))
+
+				return line(this.data)
+			},
+			circles () {
+				return this.data.map(d => {
+					return { x: this.x(d.date), y: this.y(d.pageViews) }
+				})
+			}
+		},
+		mounted () {
+			this.updateFuncs()
+
+			let resizeCb = throttle(() => {
+				this.updateFuncs()
+			}, 200)
+			window.addEventListener('resize', resizeCb)
+
+			setTimeout(() => {
+				this.loading = false;
+			}, Math.random()*3000)
+		}
+	}
+</script>
+
+<style lang='scss'>
+	@import '../../assets/scss/variables.scss';
+
+	.widgets__line_chart {
+		width: 100%;
+		height: 100%;
+		overflow: hidden;
+		border-radius: 0.25rem 0.25rem 0 0;
+		position: relative;
+
+		@at-root #{&}__overlay {
+			@include loading-overlay(#f39c12, 0.25rem 0.25rem 0 0);
+		}
+
+		@at-root #{&}__tooltip {
+			position: fixed;
+			background-color: rgba(256, 256, 256, 0.9);
+			pointer-events: none;
+			display: inline-block;
+			opacity: 0;
+			padding: 0.25rem;
+			z-index: 1;
+			border-radius: 0.25rem;
+			transition: all 0.2s;
+
+			@at-root #{&}--show {
+				opacity: 1;
+			}
+		}
+
+		@at-root #{&}__axis {
+			line {
+				stroke: #fff;
+			}
+			path {
+				stroke: #fff;
+			}
+			text {
+				fill: #fff;
+			}
+
+			@at-root #{&}--x {
+				text {
+					transform: translate(0, 2px);
+				}
+			}
+
+		}
+
+		svg {
+			height: 100%;
+			width: 100%;
+		}
+	}
+</style>

+ 85 - 0
src/components/widgets/NewPosts.vue

@@ -0,0 +1,85 @@
+<template>
+	<div class='widgets__new_post'>
+		<div class='widgets__new_post__overlay' :class='{ "widgets__new_post__overlay--show" : loading }'>
+			<loading-icon></loading-icon>
+		</div>
+		<div class='widgets__new_post__main'>
+			<template v-if='count'>
+				{{count}} new {{count | pluralize('post')}}
+			</template>
+			<template v-else>
+				No new posts
+			</template>
+		</div>
+		<div class='widgets__new_post__message'>
+			<template v-if='change === 0'>
+				<span class='fa fa-minus'></span>
+				No change since yesterday
+			</template>
+			<template v-else-if='change > 0'>
+				<span class='fa fa-caret-up'></span>
+				Up {{change}} since yesterday
+			</template>
+			<template v-else>
+				<span class='fa fa-caret-down'></span>
+				Down {{Math.abs(change)}} since yesterday
+			</template>
+		</div>
+	</div>
+</template>
+
+<script>
+	import LoadingIcon from '../LoadingIcon'
+
+	export default {
+		name: 'NewPosts',
+		components: { LoadingIcon },
+		data () {
+			return {
+				loading: true,
+				count: 1,
+				change: -2,
+			}
+		},
+		created () {
+			setTimeout(() => {
+				this.loading = false;
+			}, Math.random*3000)
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	@import '../../assets/scss/variables.scss';
+
+	.widgets__new_post {
+		background-color: #3498db;
+		color: #fff;
+		width: 100%;
+		height: 100%;
+		border-radius: 0.25rem 0.25rem 0 0;
+		display: flex;
+		position: relative;
+		flex-direction: column;
+		padding: 0.5rem;
+		align-items: center;
+		justify-content: center;
+
+		@at-root #{&}__overlay {
+			@include loading-overlay(#3498db, 0.25rem 0.25rem 0 0);
+		}
+
+		@at-root #{&}__main {
+			font-size: 2.5rem;
+			font-family: $font--role-emphasis;
+		}
+
+		@at-root #{&}__message {
+			margin-top: 0.5rem;
+
+			span {
+				margin-right: 0.25rem;
+			}
+		}
+	}
+</style>

+ 131 - 0
src/components/widgets/TopPosts.vue

@@ -0,0 +1,131 @@
+<template>
+	<div class='widgets__top_posts'>
+		<div class='widgets__top_posts__overlay' :class='{ "widgets__top_posts__overlay--show" : loading }'>
+			<loading-icon></loading-icon>
+		</div>
+
+		<div
+			class='widgets__top_posts__item'
+			:class='"widgets__top_posts__item--" + $index'
+			v-for='(thread, $index) in data'
+		>
+			<div class='widgets__top_posts__item__number' v-if='thread.title'>{{$index + 1}}</div>
+			<div class='widgets__top_posts__item__info'>
+				<div class='widgets__top_posts__item__title'>{{thread.title}}</div>
+				<div class='widgets__top_posts__item__views' v-if='thread.title'>
+					{{thread.views}} {{thread.views | pluralize('page view')}}
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+	import LoadingIcon from '../LoadingIcon'
+
+	export default {
+		name: 'TopPosts',
+		components: { LoadingIcon },
+		data () {
+			return {
+				loading: true,
+
+				data_: [
+					{ title: 'Post title here', views: 20 },
+					{ title: 'Another', views: 18 },
+					{ title: 'Lorem ipsum dolor sit amet loremp', views: 10 }
+				]
+			}
+		},
+		computed: {
+			data () {
+				let ret = []
+
+				for(let i = 0; i < 4; i++) {
+					if(this.data_[i]) {
+						ret.push(this.data_[i])
+					} else {
+						ret.push({})
+					}
+				}
+
+				return ret
+			}
+		},
+		created () {
+			setTimeout(() => {
+				this.loading = false;
+			}, Math.random()*3000)
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	@import '../../assets/scss/variables.scss';
+
+	.widgets__top_posts {
+		background-color: #fff;
+		width: 100%;
+		height: 100%;
+		overflow: auto;
+		border-radius: 0.25rem 0.25rem 0 0;
+		position: relative;
+
+
+		@at-root #{&}__overlay {
+			@include loading-overlay(rgb(160, 160, 160), 0.25rem 0.25rem 0 0);
+		}
+
+		@at-root #{&}__item {
+			display: flex;
+			flex-direction: row;
+			padding: 0.25rem 1rem;
+			cursor: default;
+			height: 25%;
+			overflow: hidden;
+			padding-top: 0.125rem;
+			transition: filter 0.2s;
+
+			&:hover {
+				filter: brightness(0.9);
+			}
+
+			@for $i from 0 through 3 {
+				@at-root #{&}--#{$i} {
+					$alpha: null;
+
+					@if $i == 3 {
+						$alpha: 0.075;
+					} @else {
+						$alpha: 0.8 - ($i + 1) / 5
+					}
+					
+					background-color: rgba(160, 160, 160, $alpha);
+				}
+			}
+
+			@at-root #{&}__number {
+				font-size: 1.75rem;
+				font-family: $font--role-emphasis;
+				margin-right: 1rem;
+				width: 1rem;
+				@include user-select(none);				
+			}
+
+			@at-root #{&}__title {
+				font-size: 1.125rem;
+				text-overflow: ellipsis;
+				width: 13rem;
+				cursor: pointer;
+				white-space: nowrap;
+				overflow: hidden;
+			}
+
+			@at-root #{&}__views {
+				color: $color__text--secondary;
+				font-size: 0.9rem;
+				margin-top: -0.125rem;
+			}
+		}
+	}
+</style>

+ 8 - 0
src/main.js

@@ -25,6 +25,10 @@ import Settings from './components/routes/Settings'
 import SettingsGeneral from './components/routes/SettingsGeneral'
 import SettingsAccount from './components/routes/SettingsAccount'
 
+import Admin from './components/routes/Admin'
+import AdminDashboard from './components/routes/AdminDashboard'
+import AdminModeration from './components/routes/AdminModeration'
+
 let { onResize } = require('./assets/js/flexBoxGridCorrect.js')
 
 onResize('.index_categories', 'index_category');
@@ -49,6 +53,10 @@ const router = new VueRouter({
 		{ path: '/settings', redirect: '/settings/general', component: Settings, children: [
 			{ path: 'general', component: SettingsGeneral },
 			{ path: 'account', component: SettingsAccount }
+		] },
+		{ path: '/admin', redirect: '/admin/dashboard', component: Admin, children: [
+			{ path: 'dashboard', component: AdminDashboard },
+			{ path: 'moderation', component: AdminModeration },
 		] }
 	],
 	mode: 'history'