diff --git a/app/templates/user.hbs b/app/templates/user.hbs
index 69ce4ce34da..4296beb19ec 100644
--- a/app/templates/user.hbs
+++ b/app/templates/user.hbs
@@ -1,11 +1,30 @@
-
- {{user-avatar user=model.user size='medium' data-test-avatar=true}}
-
- {{ model.user.login }}
-
- {{#user-link user=model.user data-test-user-link=true}}
-

- {{/user-link}}
+
+
+
+ {{user-avatar user=model.user size='medium' data-test-avatar=true}}
+
+ {{ model.user.login }}
+
+ {{#user-link user=model.user}}
+

+ {{/user-link}}
+
+
+ {{#if allowFavorting}}
+
+ {{/if}}
+
+
diff --git a/migrations/20170613160003_create_favorite_users/down.sql b/migrations/20170613160003_create_favorite_users/down.sql
new file mode 100644
index 00000000000..08b7ca5d399
--- /dev/null
+++ b/migrations/20170613160003_create_favorite_users/down.sql
@@ -0,0 +1 @@
+DROP TABLE favorite_users;
\ No newline at end of file
diff --git a/migrations/20170613160003_create_favorite_users/up.sql b/migrations/20170613160003_create_favorite_users/up.sql
new file mode 100644
index 00000000000..fdf318b29ba
--- /dev/null
+++ b/migrations/20170613160003_create_favorite_users/up.sql
@@ -0,0 +1,9 @@
+CREATE TABLE favorite_users (
+ user_id INTEGER NOT NULL,
+ target_id INTEGER NOT NULL,
+ CONSTRAINT favorites_pkey PRIMARY KEY (user_id, target_id),
+ CONSTRAINT fk_favorites_user_id FOREIGN KEY (user_id)
+ REFERENCES users (id),
+ CONSTRAINT fk_favorites_target_id FOREIGN KEY (target_id)
+ REFERENCES users (id)
+ );
\ No newline at end of file
diff --git a/migrations/20170614104258_create_index_on_favorite_users/down.sql b/migrations/20170614104258_create_index_on_favorite_users/down.sql
new file mode 100644
index 00000000000..b2a5bed9118
--- /dev/null
+++ b/migrations/20170614104258_create_index_on_favorite_users/down.sql
@@ -0,0 +1 @@
+DROP INDEX index_favorites_user_id;
\ No newline at end of file
diff --git a/migrations/20170614104258_create_index_on_favorite_users/up.sql b/migrations/20170614104258_create_index_on_favorite_users/up.sql
new file mode 100644
index 00000000000..3c27c8afc13
--- /dev/null
+++ b/migrations/20170614104258_create_index_on_favorite_users/up.sql
@@ -0,0 +1 @@
+CREATE INDEX index_favorites_user_id ON favorite_users (user_id);
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index f8f4169b4ab..04c4b2fc007 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10008,12 +10008,6 @@
"uri-js": "^4.2.2"
}
},
- "ansi-escapes": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz",
- "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==",
- "dev": true
- },
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
@@ -10040,15 +10034,6 @@
"supports-color": "^5.3.0"
}
},
- "cli-cursor": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
- "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=",
- "dev": true,
- "requires": {
- "restore-cursor": "^2.0.0"
- }
- },
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -10077,36 +10062,16 @@
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true
},
- "external-editor": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.3.tgz",
- "integrity": "sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==",
- "dev": true,
- "requires": {
- "chardet": "^0.7.0",
- "iconv-lite": "^0.4.24",
- "tmp": "^0.0.33"
- }
- },
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
"dev": true
},
- "figures": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
- "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=",
- "dev": true,
- "requires": {
- "escape-string-regexp": "^1.0.5"
- }
- },
"glob": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
- "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
+ "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
@@ -10118,53 +10083,9 @@
}
},
"globals": {
- "version": "11.11.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-11.11.0.tgz",
- "integrity": "sha512-WHq43gS+6ufNOEqlrDBxVEbb8ntfXrfAUU2ZOpCxrBdGKW3gyv8mCxAfIBD0DroPKGrJ2eSsXsLtY9MPntsyTw==",
- "dev": true
- },
- "inquirer": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.3.1.tgz",
- "integrity": "sha512-MmL624rfkFt4TG9y/Jvmt8vdmOo836U7Y0Hxr2aFk3RelZEGX4Igk0KabWrcaaZaTv9uzglOqWh1Vly+FAWAXA==",
- "dev": true,
- "requires": {
- "ansi-escapes": "^3.2.0",
- "chalk": "^2.4.2",
- "cli-cursor": "^2.1.0",
- "cli-width": "^2.0.0",
- "external-editor": "^3.0.3",
- "figures": "^2.0.0",
- "lodash": "^4.17.11",
- "mute-stream": "0.0.7",
- "run-async": "^2.2.0",
- "rxjs": "^6.4.0",
- "string-width": "^2.1.0",
- "strip-ansi": "^5.1.0",
- "through": "^2.3.6"
- },
- "dependencies": {
- "ansi-regex": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
- "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
- "dev": true
- },
- "strip-ansi": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
- "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
- "dev": true,
- "requires": {
- "ansi-regex": "^4.1.0"
- }
- }
- }
- },
- "is-fullwidth-code-point": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
- "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true
},
"js-yaml": {
@@ -10190,61 +10111,17 @@
"dev": true
},
"ms": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
- "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
- "dev": true
- },
- "mute-stream": {
- "version": "0.0.7",
- "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
- "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
- "onetime": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz",
- "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=",
- "dev": true,
- "requires": {
- "mimic-fn": "^1.0.0"
- }
- },
- "restore-cursor": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz",
- "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=",
- "dev": true,
- "requires": {
- "onetime": "^2.0.0",
- "signal-exit": "^3.0.2"
- }
- },
- "rxjs": {
- "version": "6.4.0",
- "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz",
- "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==",
- "dev": true,
- "requires": {
- "tslib": "^1.9.0"
- }
- },
"semver": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz",
"integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==",
"dev": true
},
- "string-width": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
- "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
- "dev": true,
- "requires": {
- "is-fullwidth-code-point": "^2.0.0",
- "strip-ansi": "^4.0.0"
- }
- },
"strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
@@ -10262,15 +10139,6 @@
"requires": {
"has-flag": "^3.0.0"
}
- },
- "tmp": {
- "version": "0.0.33",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
- "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
- "dev": true,
- "requires": {
- "os-tmpdir": "~1.0.2"
- }
}
}
},
@@ -11546,9 +11414,9 @@
},
"dependencies": {
"glob": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
- "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
+ "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
@@ -21523,9 +21391,9 @@
"dev": true
},
"table": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/table/-/table-5.2.3.tgz",
- "integrity": "sha512-N2RsDAMvDLvYwFcwbPyF3VmVSSkuF+G1e+8inhBLtHpvwXGw4QRPEZhihQNeEN0i1up6/f6ObCJXNdlRG3YVyQ==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/table/-/table-5.4.1.tgz",
+ "integrity": "sha512-E6CK1/pZe2N75rGZQotFOdmzWQ1AILtgYbMAbAjvms0S1l5IDB47zG3nCnFGB/w+7nB3vKofbLXCH7HPBo864w==",
"dev": true,
"requires": {
"ajv": "^6.9.1",
diff --git a/public/assets/delete.png b/public/assets/delete.png
new file mode 100644
index 00000000000..dae3af91637
Binary files /dev/null and b/public/assets/delete.png differ
diff --git a/public/assets/favorite.png b/public/assets/favorite.png
new file mode 100644
index 00000000000..85a0fe542af
Binary files /dev/null and b/public/assets/favorite.png differ
diff --git a/src/controllers/user/me.rs b/src/controllers/user/me.rs
index d8100eded9e..f8473f3136a 100644
--- a/src/controllers/user/me.rs
+++ b/src/controllers/user/me.rs
@@ -5,9 +5,9 @@ use crate::email;
use crate::util::bad_request;
use crate::util::errors::CargoError;
-use crate::models::{Email, Follow, NewEmail, User, Version};
-use crate::schema::{crates, emails, follows, users, versions};
-use crate::views::{EncodableMe, EncodableVersion};
+use crate::models::{Email, FavoriteUser, Follow, NewEmail, User, Version};
+use crate::schema::{crates, emails, favorite_users, follows, users, versions};
+use crate::views::{EncodableMe, EncodablePublicUser, EncodableVersion};
/// Handles the `GET /me` route.
pub fn me(req: &mut dyn Request) -> CargoResult {
@@ -45,6 +45,78 @@ pub fn me(req: &mut dyn Request) -> CargoResult {
}))
}
+fn favorite_target(req: &mut dyn Request) -> CargoResult {
+ let user = req.user()?;
+ let target_user_id: i32 = req.params()["user_id"].parse().expect("User ID not found");
+ Ok(FavoriteUser {
+ user_id: user.id,
+ target_id: target_user_id,
+ })
+}
+
+/// Handles the `PUT /users/:user_id/favorite` route.
+pub fn favorite(req: &mut dyn Request) -> CargoResult {
+ let favorite = favorite_target(req)?;
+ let conn = req.db_conn()?;
+ diesel::insert_into(favorite_users::table)
+ .values(&favorite)
+ .on_conflict_do_nothing()
+ .execute(&*conn)?;
+ #[derive(Serialize)]
+ struct R {
+ ok: bool,
+ }
+ Ok(req.json(&R { ok: true }))
+}
+
+/// Handles the `DELETE /users/:user_id/favorite` route.
+pub fn unfavorite(req: &mut dyn Request) -> CargoResult {
+ let favorite = favorite_target(req)?;
+ let conn = req.db_conn()?;
+ diesel::delete(&favorite).execute(&*conn)?;
+ #[derive(Serialize)]
+ struct R {
+ ok: bool,
+ }
+ Ok(req.json(&R { ok: true }))
+}
+
+/// Handles the `GET /users/:user_id/favorited` route.
+pub fn favorited(req: &mut dyn Request) -> CargoResult {
+ use diesel::expression::dsl::exists;
+
+ let fav = favorite_target(req)?;
+ let conn = req.db_conn()?;
+ let favorited =
+ diesel::select(exists(favorite_users::table.find(fav.id()))).get_result(&*conn)?;
+ #[derive(Serialize)]
+ struct R {
+ favorited: bool,
+ }
+ Ok(req.json(&R { favorited }))
+}
+
+/// Handles the `GET /users/:user_id/favorite_users` route.
+pub fn favorite_users(req: &mut dyn Request) -> CargoResult {
+ let user_id: i32 = req.params()["user_id"].parse().expect("User ID not found");
+ let conn = req.db_conn()?;
+
+ let users = users::table
+ .inner_join(favorite_users::table)
+ .filter(favorite_users::user_id.eq(user_id))
+ .select(users::all_columns)
+ .load::(&*conn)?
+ .into_iter()
+ .map(User::encodable_public)
+ .collect();
+
+ #[derive(Serialize)]
+ struct R {
+ users: Vec,
+ }
+ Ok(req.json(&R { users }))
+}
+
/// Handles the `GET /me/updates` route.
pub fn updates(req: &mut dyn Request) -> CargoResult {
use diesel::dsl::any;
diff --git a/src/models.rs b/src/models.rs
index 9968177d294..ca7c41e2054 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -11,7 +11,7 @@ pub use self::owner::{CrateOwner, Owner, OwnerKind};
pub use self::rights::Rights;
pub use self::team::{NewTeam, Team};
pub use self::token::ApiToken;
-pub use self::user::{NewUser, User};
+pub use self::user::{FavoriteUser, NewUser, User};
pub use self::version::{NewVersion, Version};
pub mod helpers;
diff --git a/src/models/user.rs b/src/models/user.rs
index 2f22c20a19e..7ce2fe12b7d 100644
--- a/src/models/user.rs
+++ b/src/models/user.rs
@@ -6,7 +6,7 @@ use crate::app::App;
use crate::util::CargoResult;
use crate::models::{Crate, CrateOwner, Email, NewEmail, Owner, OwnerKind, Rights};
-use crate::schema::{crate_owners, emails, users};
+use crate::schema::{crate_owners, emails, favorite_users, users};
use crate::views::{EncodablePrivateUser, EncodablePublicUser};
/// The model representing a row in the `users` database table.
@@ -32,6 +32,14 @@ pub struct NewUser<'a> {
pub gh_access_token: Cow<'a, str>,
}
+#[derive(Copy, Clone, Debug, Insertable, Queryable, Identifiable, Associations)]
+#[primary_key(user_id, target_id)]
+#[table_name = "favorite_users"]
+pub struct FavoriteUser {
+ pub user_id: i32,
+ pub target_id: i32,
+}
+
impl<'a> NewUser<'a> {
pub fn new(
gh_id: i32,
diff --git a/src/router.rs b/src/router.rs
index efea8b8f680..003bfab9525 100644
--- a/src/router.rs
+++ b/src/router.rs
@@ -76,6 +76,13 @@ pub fn build_router(app: &App) -> R404 {
api_router.put("/users/:user_id", C(user::me::update_user));
api_router.get("/users/:user_id/stats", C(user::other::stats));
api_router.get("/teams/:team_id", C(team::show_team));
+ api_router.get("/users/:user_id/favorited", C(user::me::favorited));
+ api_router.put("/users/:user_id/favorite", C(user::me::favorite));
+ api_router.delete("/users/:user_id/favorite", C(user::me::unfavorite));
+ api_router.get(
+ "/users/:user_id/favorite_users",
+ C(user::me::favorite_users),
+ );
api_router.get("/me", C(user::me::me));
api_router.get("/me/updates", C(user::me::updates));
api_router.get("/me/tokens", C(token::list));
diff --git a/src/schema.rs b/src/schema.rs
index 6fd4bfb22d7..a4a0dd7b151 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -943,6 +943,13 @@ table! {
}
}
+table! {
+ favorite_users (user_id, target_id) {
+ user_id -> Int4,
+ target_id -> Int4,
+ }
+}
+
table! {
use diesel::sql_types::*;
use diesel_full_text_search::{TsVector as Tsvector};
@@ -978,6 +985,7 @@ joinable!(crates_keywords -> keywords (keyword_id));
joinable!(dependencies -> crates (crate_id));
joinable!(dependencies -> versions (version_id));
joinable!(emails -> users (user_id));
+joinable!(favorite_users -> users (target_id));
joinable!(follows -> crates (crate_id));
joinable!(follows -> users (user_id));
joinable!(publish_limit_buckets -> users (user_id));
@@ -1003,6 +1011,7 @@ allow_tables_to_appear_in_same_query!(
crates_keywords,
dependencies,
emails,
+ favorite_users,
follows,
keywords,
metadata,