├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── client ├── babel.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ └── index.html └── src │ ├── App.vue │ ├── components │ ├── add-answer-modal.vue │ ├── add-question-modal.vue │ ├── question-preview.vue │ └── question-score.vue │ ├── main.js │ ├── question-hub.js │ ├── router.js │ └── views │ ├── home.vue │ └── question.vue └── server ├── Controllers └── QuestionController.cs ├── Hubs └── QuestionHub.cs ├── Models ├── Answer.cs └── Question.cs ├── Program.cs ├── Properties └── launchSettings.json ├── Startup.cs ├── appsettings.Development.json ├── appsettings.json └── server.csproj /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/server/bin/Debug/netcoreapp2.2/server.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/server", 16 | "stopAtEntry": false, 17 | "internalConsoleOptions": "openOnSessionStart", 18 | "launchBrowser": { 19 | "enabled": true, 20 | "args": "${auto-detect-url}", 21 | "windows": { 22 | "command": "cmd.exe", 23 | "args": "/C start ${auto-detect-url}" 24 | }, 25 | "osx": { 26 | "command": "open" 27 | }, 28 | "linux": { 29 | "command": "xdg-open" 30 | } 31 | }, 32 | "env": { 33 | "ASPNETCORE_ENVIRONMENT": "Development" 34 | }, 35 | "sourceFileMap": { 36 | "/Views": "${workspaceFolder}/Views" 37 | } 38 | }, 39 | { 40 | "name": ".NET Core Attach", 41 | "type": "coreclr", 42 | "request": "attach", 43 | "processId": "${command:pickProcess}" 44 | } 45 | ,] 46 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/server/server.csproj" 11 | ], 12 | "problemMatcher": "$msCompile" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Amine Smahi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stackunderflow 2 | A minimalistic version of StackOverflow.com using an ASP.NET Core backend, and a Vue.js frontend. 3 | 4 | ### What is stackoverflow 5 | Stack Overflow is a question and answer site for professional and enthusiast programmers. for the info stackoverflow is built with ASP.NET Core *-* 6 | 7 | ## Project setup 8 | ``` 9 | cd client 10 | npm install 11 | ``` 12 | 13 | ### Compiles and hot-reloads for the client project 14 | ``` 15 | npm run serve 16 | ``` 17 | 18 | ### Compiles and minifies for production 19 | ``` 20 | npm run build 21 | ``` 22 | 23 | ### Run your tests 24 | ``` 25 | npm run test 26 | ``` 27 | 28 | ### Lints and fixes files 29 | ``` 30 | npm run lint 31 | ``` 32 | 33 | ### Run the web api 34 | ``` 35 | cd server 36 | dotnet restore 37 | dotnet run 38 | ``` 39 | 40 | ### Screenshots 41 | 42 | ![image](https://user-images.githubusercontent.com/24621701/52903500-a16a0b80-321e-11e9-8022-afbafd8ceefe.png) 43 | ![image](https://user-images.githubusercontent.com/24621701/52903504-a464fc00-321e-11e9-9df8-6bea2af59f9c.png) 44 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "so-signalr", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@aspnet/signalr": "^1.1.0", 12 | "vue": "^2.5.21", 13 | "vue-router": "^3.0.1" 14 | }, 15 | "devDependencies": { 16 | "@fortawesome/fontawesome-free": "^5.6.3", 17 | "@vue/cli-plugin-babel": "^3.2.0", 18 | "@vue/cli-plugin-eslint": "^3.2.0", 19 | "@vue/cli-service": "^3.2.0", 20 | "@vue/eslint-config-standard": "^4.0.0", 21 | "axios": "^0.18.0", 22 | "babel-eslint": "^10.0.1", 23 | "bootstrap-vue": "^2.0.0-rc.11", 24 | "eslint": "^5.8.0", 25 | "eslint-plugin-vue": "^5.0.0", 26 | "vue-markdown": "^2.2.4", 27 | "vue-template-compiler": "^2.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Amine-Smahi/stackunderflow/b8ea5f7befdf571fc99d77da6ab889c31269b64d/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | stackunderflow 9 | 10 | 11 | 14 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /client/src/components/add-answer-modal.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 54 | -------------------------------------------------------------------------------- /client/src/components/add-question-modal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 56 | -------------------------------------------------------------------------------- /client/src/components/question-preview.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 51 | -------------------------------------------------------------------------------- /client/src/components/question-score.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App' 3 | import router from './router' 4 | import axios from 'axios' 5 | import BootstrapVue from 'bootstrap-vue' 6 | import QuestionHub from './question-hub' 7 | import 'bootstrap/dist/css/bootstrap.css' 8 | import 'bootstrap-vue/dist/bootstrap-vue.css' 9 | import '@fortawesome/fontawesome-free/css/all.css' 10 | 11 | Vue.config.productionTip = false 12 | 13 | // Setup axios as the Vue default $http library 14 | axios.defaults.baseURL = 'http://localhost:5100' // same as the Url the server listens to 15 | Vue.prototype.$http = axios 16 | 17 | // Install Vue extensions 18 | Vue.use(BootstrapVue) 19 | Vue.use(QuestionHub) 20 | 21 | new Vue({ 22 | router, 23 | render: h => h(App) 24 | }).$mount('#app') 25 | -------------------------------------------------------------------------------- /client/src/question-hub.js: -------------------------------------------------------------------------------- 1 | import { HubConnectionBuilder, LogLevel } from '@aspnet/signalr' 2 | 3 | export default { 4 | install (Vue) { 5 | const connection = new HubConnectionBuilder() 6 | .withUrl(`${Vue.prototype.$http.defaults.baseURL}/question-hub`) 7 | .configureLogging(LogLevel.Information) 8 | .build() 9 | 10 | const questionHub = new Vue() // use new Vue instance as an event bus 11 | 12 | // Forward hub events through the event, so we can listen for them in the Vue components 13 | connection.on('QuestionScoreChange', (questionId, score) => { 14 | questionHub.$emit('score-changed', { questionId, score }) 15 | }) 16 | connection.on('AnswerCountChange', (questionId, answerCount) => { 17 | questionHub.$emit('answer-count-changed', { questionId, answerCount }) 18 | }) 19 | connection.on('AnswerAdded', answer => { 20 | questionHub.$emit('answer-added', answer) 21 | }) 22 | 23 | // Provide methods for components to send messages back to server 24 | // Make sure no invocation happens until the connection is established 25 | questionHub.questionOpened = (questionId) => { 26 | return startedPromise 27 | .then(() => connection.invoke('JoinQuestionGroup', questionId)) 28 | .catch(console.error) 29 | } 30 | questionHub.questionClosed = (questionId) => { 31 | return startedPromise 32 | .then(() => connection.invoke('LeaveQuestionGroup', questionId)) 33 | .catch(console.error) 34 | } 35 | 36 | // Add the hub to the Vue prototype, so every component can listen to events or send new events using this.$questionHub 37 | Vue.prototype.$questionHub = questionHub 38 | 39 | // You need to call connection.start() to establish the connection 40 | // the client wont handle reconnecting for you! Docs recommend listening onclose 41 | // and handling it there. This is the simplest of the strategies 42 | let startedPromise = null 43 | function start () { 44 | startedPromise = connection.start() 45 | .catch(err => { 46 | console.error('Failed to connect with hub', err) 47 | return new Promise((resolve, reject) => setTimeout(() => start().then(resolve).catch(reject), 5000)) 48 | }) 49 | return startedPromise 50 | } 51 | connection.onclose(() => start()) 52 | 53 | start() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import HomePage from '@/views/home' 4 | import QuestionPage from '@/views/question' 5 | 6 | Vue.use(Router) 7 | 8 | export default new Router({ 9 | routes: [ 10 | { 11 | path: '/', 12 | name: 'Home', 13 | component: HomePage 14 | }, 15 | { 16 | path: '/question/:id', 17 | name: 'Question', 18 | component: QuestionPage 19 | } 20 | ] 21 | }) 22 | -------------------------------------------------------------------------------- /client/src/views/home.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 46 | 47 | 52 | -------------------------------------------------------------------------------- /client/src/views/question.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 75 | -------------------------------------------------------------------------------- /server/Controllers/QuestionController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Concurrent; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.SignalR; 9 | using server.Hubs; 10 | using server.Models; 11 | 12 | namespace server.Controllers 13 | { 14 | [Route("api/[controller]")] 15 | [ApiController] 16 | public class QuestionController : ControllerBase 17 | { 18 | private readonly IHubContext hubContext; 19 | private static ConcurrentBag questions = new ConcurrentBag { 20 | new Question { 21 | Id = Guid.Parse("b00c58c0-df00-49ac-ae85-0a135f75e01b"), 22 | Title = "Welcome", 23 | Body = "Welcome to the _mini Stack Overflow_ rip-off!\nThis will help showcasing **SignalR** and its integration with **Vue**", 24 | Answers = new List{ new Answer { Body = "Sample answer" }} 25 | } 26 | }; 27 | 28 | public QuestionController(IHubContext questionHub) 29 | { 30 | this.hubContext = questionHub; 31 | } 32 | 33 | [HttpGet()] 34 | public IEnumerable GetQuestions() 35 | { 36 | return questions.Where(t => !t.Deleted).Select(q => new { 37 | Id = q.Id, 38 | Title = q.Title, 39 | Body = q.Body, 40 | Score = q.Score, 41 | AnswerCount = q.Answers.Count 42 | }); 43 | } 44 | 45 | [HttpGet("{id}")] 46 | public ActionResult GetQuestion(Guid id) 47 | { 48 | var question = questions.SingleOrDefault(t => t.Id == id && !t.Deleted); 49 | if (question == null) return NotFound(); 50 | 51 | return new JsonResult(question); 52 | } 53 | 54 | [HttpPost()] 55 | public Question AddQuestion([FromBody]Question question) 56 | { 57 | question.Id = Guid.NewGuid(); 58 | question.Deleted = false; 59 | question.Answers = new List(); 60 | questions.Add(question); 61 | return question; 62 | } 63 | 64 | [HttpPost("{id}/answer")] 65 | public async Task AddAnswerAsync(Guid id, [FromBody]Answer answer) 66 | { 67 | var question = questions.SingleOrDefault(t => t.Id == id && !t.Deleted); 68 | if (question == null) return NotFound(); 69 | 70 | answer.Id = Guid.NewGuid(); 71 | answer.QuestionId = id; 72 | answer.Deleted = false; 73 | question.Answers.Add(answer); 74 | 75 | // Notify anyone connected to the group for this answer 76 | await this.hubContext.Clients.Group(id.ToString()).AnswerAdded(answer); 77 | // Notify every client 78 | await this.hubContext.Clients.All.AnswerCountChange(question.Id, question.Answers.Count); 79 | 80 | return new JsonResult(answer); 81 | } 82 | 83 | [HttpPatch("{id}/upvote")] 84 | public async Task UpvoteQuestionAsync(Guid id) 85 | { 86 | var question = questions.SingleOrDefault(t => t.Id == id && !t.Deleted); 87 | if (question == null) return NotFound(); 88 | 89 | // Warning, this isnt really atomic! 90 | question.Score++; 91 | 92 | // Notify every client 93 | await this.hubContext.Clients.All.QuestionScoreChange(question.Id, question.Score); 94 | 95 | return new JsonResult(question); 96 | } 97 | 98 | [HttpPatch("{id}/downvote")] 99 | public async Task DownvoteQuestionAsync(Guid id) 100 | { 101 | var question = questions.SingleOrDefault(t => t.Id == id && !t.Deleted); 102 | if (question == null) return NotFound(); 103 | 104 | // Warning, this isnt really atomic 105 | question.Score--; 106 | 107 | // Notify every client 108 | await this.hubContext.Clients.All.QuestionScoreChange(question.Id, question.Score); 109 | 110 | return new JsonResult(question); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /server/Hubs/QuestionHub.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.SignalR; 4 | using server.Models; 5 | 6 | namespace server.Hubs 7 | { 8 | public interface IQuestionHub 9 | { 10 | Task QuestionScoreChange(Guid questionId, int score); 11 | Task AnswerCountChange(Guid questionId, int answerCount); 12 | Task AnswerAdded(Answer answer); 13 | } 14 | 15 | public class QuestionHub: Hub 16 | { 17 | // No need to implement here the methods defined by IQuestionHub, their purpose is simply 18 | // to provide a strongly typed interface. 19 | // Users of IHubContext still have to decide to whom should the events be sent 20 | // as in: await this.hubContext.Clients.All.SendQuestionScoreChange(question.Id, question.Score); 21 | 22 | // These 2 methods will be called from the client 23 | public async Task JoinQuestionGroup(Guid questionId) 24 | { 25 | await Groups.AddToGroupAsync(Context.ConnectionId, questionId.ToString()); 26 | } 27 | public async Task LeaveQuestionGroup(Guid questionId) 28 | { 29 | await Groups.RemoveFromGroupAsync(Context.ConnectionId, questionId.ToString()); 30 | 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /server/Models/Answer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace server.Models 4 | { 5 | public class Answer 6 | { 7 | public Guid Id { get; set; } 8 | public Guid QuestionId { get; set; } 9 | public string Body { get; set; } 10 | public int Score { get; set; } 11 | public bool Deleted { get;set; } 12 | } 13 | } -------------------------------------------------------------------------------- /server/Models/Question.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace server.Models 5 | { 6 | public class Question 7 | { 8 | public Guid Id { get; set; } 9 | public string Title { get; set; } 10 | public string Body { get; set; } 11 | public int Score { get; set; } 12 | public bool Deleted { get;set; } 13 | public List Answers { get;set; } 14 | } 15 | } -------------------------------------------------------------------------------- /server/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace server 12 | { 13 | public class Program 14 | { 15 | public static void Main(string[] args) 16 | { 17 | CreateWebHostBuilder(args).Build().Run(); 18 | } 19 | 20 | public static IWebHostBuilder CreateWebHostBuilder(string[] args) => 21 | WebHost.CreateDefaultBuilder(args) 22 | .UseStartup() 23 | .UseUrls("http://localhost:5100"); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:59496", 8 | "sslPort": 44368 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "api/values", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "server": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "api/values", 24 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /server/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.HttpsPolicy; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Logging; 12 | using Microsoft.Extensions.Options; 13 | using server.Hubs; 14 | 15 | namespace server 16 | { 17 | public class Startup 18 | { 19 | public Startup(IConfiguration configuration) 20 | { 21 | Configuration = configuration; 22 | } 23 | 24 | public IConfiguration Configuration { get; } 25 | 26 | // This method gets called by the runtime. Use this method to add services to the container. 27 | public void ConfigureServices(IServiceCollection services) 28 | { 29 | services.AddCors(); 30 | services.AddSignalR(); 31 | services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 32 | } 33 | 34 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 35 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) 36 | { 37 | if (env.IsDevelopment()) 38 | { 39 | app.UseDeveloperExceptionPage(); 40 | } 41 | else 42 | { 43 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 44 | app.UseHsts(); 45 | } 46 | 47 | // Enable CORS so the Vue client can send requests 48 | app.UseCors(builder => 49 | builder 50 | .WithOrigins("http://localhost:8080") 51 | .AllowAnyMethod() 52 | .AllowAnyHeader() 53 | .AllowCredentials() 54 | ); 55 | 56 | // Register SignalR hubs 57 | app.UseSignalR(route => 58 | { 59 | route.MapHub("/question-hub"); 60 | }); 61 | 62 | app.UseHttpsRedirection(); 63 | app.UseMvc(); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*" 8 | } 9 | -------------------------------------------------------------------------------- /server/server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.2 5 | InProcess 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | --------------------------------------------------------------------------------